Rails cto minitest
Write and maintain Minitest tests for a Rails 8 application using the Minitest::Spec DSL. Covers model tests, command/service tests, controller/integration tests, job tests, ViewComponent tests, and system tests. Use this skill whenever writing, fixing, or reviewing tests, or when the user asks to add test coverage, write specs, create test cases, or debug failing tests. Also trigger when generating new models, controllers, commands, jobs, or components that need accompanying tests.From its SKILL.md
npx -y skills add mattsears/rails-cto --skill rails-cto-minitestAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 14 stars14 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
18.6 KB, as published. Nobody here has run it
Rails Minitest Skill
This project uses Minitest::Spec DSL — not RSpec, not vanilla Minitest::Test. Every test file follows a consistent structure. Study it before writing anything.
Pre-flight: Verify SimpleCov JSON Export
Before running any tests, confirm that test/test_helper.rb configures SimpleCov to export JSON. Look for SimpleCov::Formatter::JSONFormatter in the formatter config. The expected setup is:
SimpleCov.start("rails") do
formatter SimpleCov::Formatter::MultiFormatter.new([
SimpleCov::Formatter::HTMLFormatter,
SimpleCov::Formatter::JSONFormatter
])
end
If JSONFormatter is missing, add it so that coverage/coverage.json is generated when tests run with COVERAGE=1. Without this file, the post-test coverage check cannot run. Also verify the simplecov_json_formatter gem is in the Gemfile's test group — if not, add it.
Pre-flight: Scope Changes and Identify Test Gaps
Before writing or running tests, understand what's changed on this branch.
-
Scope the changes:
git diff staging...HEAD --stat git diff staging...HEAD --name-onlyThis tells you which files were added or modified. Focus testing efforts on these files only.
-
Map changed files to test files. For each changed
.rbfile, find its test counterpart:app/models/post.rb→test/models/post_test.rbapp/controllers/posts_controller.rb→test/controllers/posts_controller_test.rbapp/commands/posts/create.rb→test/commands/posts/create_test.rbapp/jobs/analyze_post_job.rb→test/jobs/analyze_post_job_test.rbapp/components/forms/combo_select.rb→test/components/forms/combo_select_test.rb
-
Check for missing tests. If a changed file has no corresponding test file, create one. New code ships with tests — no exceptions.
-
Check coverage for existing tests. Run the tests for the changed files with coverage enabled:
COVERAGE=1 PARALLEL=1 rails test test/path/to/relevant_test.rbReview the SimpleCov output. If coverage for the changed file is low (methods without test coverage, branches not exercised), add tests to fill the gaps. Prioritize:
- Public methods with no tests at all
- Conditional branches (if/else, case/when) that aren't covered
- Error handling paths
- Edge cases for newly added logic
-
For changed JavaScript files, check if corresponding JS tests exist. If not, create them. Run with
yarn test.
Core DSL
Use describe, it, let, subject, and before blocks. Never use def test_* method style.
# frozen_string_literal: true
require "test_helper"
class ThingTest < ActiveSupport::TestCase
let(:account) { Fabricate(:account) }
subject { account.profile }
describe "#method_name" do
it "describes what the method does" do
assert_equal "expected", subject.method_name
end
end
end
Key conventions
letblocks — lazy-loaded supporting data (accounts, parent records, config). Reserveletfor dependencies, not the primary object being tested.beforeblocks — setup that runs before each test in the enclosingdescribe.describeblocks — group by method ("#instance_method",".class_method") or behavior ("when logged in","with invalid input").itblocks — one behavior per test. Name describes the scenario, not the assertion.
The subject Rule (MANDATORY — DO NOT SKIP)
Every test class MUST define a subject block exactly once at the top of the class. This is non-negotiable. A test file without subject is incomplete. Do NOT use let for the primary object, do NOT instantiate it inline inside it blocks, and do NOT reassign subject inside it or describe blocks.
subject is the primary object under test:
- Model test →
subjectis an instance of the model - Command/service test →
subjectis an instance of the command's input record - Controller test →
subjectis the authenticated account - Job test →
subjectis the job instance or the record being processed - Component test →
subjectis the component instance
Varying subject across scenarios
When different tests need different attributes on the primary object, use a let(:attributes) block that subject references. Each nested describe overrides let(:attributes) to customize for that scenario. Never reassign subject inline.
# CORRECT — subject declared once, variations via let(:attributes)
class PlanDecoratorTest < ActiveSupport::TestCase
let(:attributes) { {} }
subject { Fabricate.build(:plan, **attributes) }
describe "#price_summary" do
describe "with monthly interval" do
let(:attributes) { { amount: 1200, interval: "month" } }
it "returns formatted price" do
assert_equal "$12.00 / month", subject.price_summary
end
end
describe "with yearly interval" do
let(:attributes) { { amount: 9600, interval: "year" } }
it "returns formatted price" do
assert_equal "$96.00 / year", subject.price_summary
end
end
describe "with zero amount" do
let(:attributes) { { amount: 0, interval: "month" } }
it "handles zero" do
assert_equal "$0.00 / month", subject.price_summary
end
end
end
end
# WRONG — subject reassigned inline in each test
class PlanDecoratorTest < ActiveSupport::TestCase
describe "#price_summary" do
it "returns formatted price with monthly interval" do
subject = Fabricate.build(:plan, amount: 1200, interval: "month")
assert_equal "$12.00 / month", subject.price_summary
end
it "returns formatted price with yearly interval" do
subject = Fabricate.build(:plan, amount: 9600, interval: "year")
assert_equal "$96.00 / year", subject.price_summary
end
end
end
# ALSO WRONG — no subject, object created with a local variable
class PostTest < ActiveSupport::TestCase
describe "#host" do
it "returns the host from the URL" do
post = Fabricate.build(:post, url: "https://example.com/path")
assert_equal "example.com", post.host
end
end
end
When subject doesn't need variations
If every test uses the same attributes, define subject directly without the let(:attributes) pattern:
# CORRECT — simple subject, no variations needed
class PostTest < ActiveSupport::TestCase
let(:account) { Fabricate(:account) }
subject { Fabricate.build(:post, owner: account, url: "https://example.com/path") }
describe "#host" do
it "returns the host from the URL" do
assert_equal "example.com", subject.host
end
end
describe "#valid?" do
it "requires a URL" do
subject.url = nil
assert_not subject.valid?
end
end
end
If you are writing a test and have not defined subject at the top of the class, stop and add it before continuing.
Assertions
Use assert-style exclusively. Never use expect(...).to syntax.
assert something # truthy
assert_not something # falsy
assert_equal expected, actual # equality
assert_nil value # nil check
assert_raises(ErrorClass) { ... } # exception
assert_difference -> { Model.count }, 1 # count change
assert_no_difference -> { Model.count } # no change
assert_in_delta expected, actual, 0.0001 # float comparison
assert_match(/pattern/, string) # regex match
assert_includes collection, item # inclusion
Test Data
Two systems coexist. Fabrication is the primary tool for most tests. FactoryBot is used in controller/integration tests that need accounts with subscriptions.
Fabrication (preferred)
# Simple creation
let(:post) { Fabricate(:post) }
# With overrides
let(:post) { Fabricate(:post, owner: account, title: "Custom") }
# Build without saving
post = Fabricate.build(:post, url: "https://example.com")
# Named variants
Fabricate(:pinned_post)
Fabricate(:account_with_profile)
Fabricators live in test/fabricators/. Check existing fabricators before creating new ones.
FactoryBot (for integration tests)
let(:account) { create(:account_with_subscriptions) }
FactoryBot is included via FactoryBot::Syntax::Methods in ActiveSupport::TestCase,
so create, build, and create_list are available everywhere.
Test Types
Model Tests
Extend ActiveSupport::TestCase. Test model methods, validations, callbacks, and scopes.
Always define subject as an instance of the model being tested.
class PostTest < ActiveSupport::TestCase
subject { Fabricate.build(:post, url: "https://example.com/path") }
describe "#host" do
it "returns the host from the URL" do
assert_equal "example.com", subject.host
end
end
end
For callback tests, use ActiveJob::TestHelper:
describe "callbacks" do
include ActiveJob::TestHelper
it "enqueues job when relevant field changes" do
assert_enqueued_with(job: SomeJob) do
subject.update!(about: "new value")
end
end
it "does not enqueue job for unrelated changes" do
subject # ensure record exists
perform_enqueued_jobs # clear queue
assert_no_enqueued_jobs(only: SomeJob) do
subject.update!(name: "New Name")
end
end
end
Command / Service Tests
Commands use .run() and return result objects with .success? / .failed?.
Wrap in the command's module namespace.
module Posts
class DestroyTest < ActiveSupport::TestCase
let(:account) { Fabricate(:account) }
subject { Fabricate(:post, owner: account) }
describe "#run" do
it "permanently deletes the post" do
post_id = subject.id
Posts::Destroy.run(post: subject)
assert_not Post.exists?(post_id)
end
it "returns a successful result" do
result = Posts::Destroy.run(post: subject)
assert result.success?
end
end
end
end
Controller / Integration Tests
Extend ActionDispatch::IntegrationTest. Use sign_in helper for authentication.
Test HTTP verbs, response codes, and response body content.
class CollectionsControllerTest < ActionDispatch::IntegrationTest
let(:account) { create(:account_with_subscriptions) }
before { sign_in(account) }
describe "GET #index" do
it "returns a successful response" do
get collections_path
assert_response :success
end
end
describe "POST #create" do
it "creates a new record" do
assert_difference -> { Collection.count }, 1 do
post collections_path,
params: { collection: { title: "New" } },
as: :turbo_stream
end
end
it "returns success for turbo stream" do
post collections_path,
params: { collection: { title: "New" } },
as: :turbo_stream
assert_response :success
end
end
end
Turbo Stream requests: pass as: :turbo_stream and check response.body for
stream element IDs with assert_match.
Job Tests
Can use either describe JobClass directly or wrap in a class. Mock dependencies
with Mocha.
describe AnalyzeFeedPostJob do
let(:post) { Fabricate(:post) }
let(:job) { AnalyzeFeedPostJob.new }
describe "#perform" do
it "processes the post" do
mock_result = mock
mock_result.expects(:success?).returns(true)
mock_result.expects(:summary).returns("A summary").at_least_once
SomeService.expects(:run)
.with(post: post)
.returns(mock_result)
job.perform(post.id)
post.reload
assert_equal "A summary", post.summary
end
it "raises for missing records" do
assert_raises(ActiveRecord::RecordNotFound) do
job.perform(999_999)
end
end
end
end
ViewComponent Tests
Extend ViewComponent::TestCase. Use render_inline and assert_selector.
module Forms
class ComboSelectTest < ViewComponent::TestCase
describe "Forms::ComboSelect" do
it "renders with the correct name" do
render_inline(Forms::ComboSelect.new(url: "/search.json", name: "item[id]"))
assert_selector "select[name='item[id]']"
end
it "sets stimulus controller data attribute" do
render_inline(Forms::ComboSelect.new(url: "/search.json", name: "item[id]"))
assert_selector "[data-controller='forms--combo-select']"
end
end
end
end
System Tests
Extend ApplicationSystemTestCase. Require application_system_test_case (not
test_helper). Use Capybara DSL: visit, fill_in, click_on, assert_selector.
require "application_system_test_case"
class LoginTest < ApplicationSystemTestCase
let(:profile) { create(:profile) }
subject { profile.account }
describe "login" do
it "signs in with email and password" do
visit "/login"
fill_in "login", with: subject.email
fill_in "password", with: "nopass"
click_on "Sign in"
assert_selector "h2", text: "TOP SITES"
end
end
end
Mocking with Mocha
Mocha is the mocking library. Key patterns:
# Stub a method on any instance
Account.any_instance.stubs(:can_create_collection?).returns(false)
# Expect a method call (fails if not called)
SomeService.expects(:run).with(arg: value).returns(result)
# Expect never called
SomeService.expects(:run).never
# Create a mock object
mock_result = mock
mock_result.expects(:success?).returns(true)
# Stub on a specific instance
job.stubs(:fetch_content).returns("text")
HTTP Stubbing
Use WebMock to keep tests off the network. Any third-party service the app talks to — search engines, AI providers, payment processors, webhooks, internal APIs — should be stubbed in test/test_helper.rb so individual tests inherit safe defaults and never hit a real endpoint.
Check test_helper.rb for the stubs already in place before writing new ones; duplicating a global stub in a single test usually means the test is fighting the helper.
For additional HTTP stubs in individual tests:
stub_request(:get, "https://api.example.com/data")
.to_return(status: 200, body: { key: "value" }.to_json,
headers: { "Content-Type" => "application/json" })
Authentication in Tests
# Integration/controller tests — already included
before { sign_in(account) }
# sign_in stubs both current_account and authenticate on ApplicationController
File Organization
test/
models/ # ActiveSupport::TestCase
commands/ # ActiveSupport::TestCase, namespaced modules
controllers/ # ActionDispatch::IntegrationTest
jobs/ # describe JobClass or ActiveSupport::TestCase
components/ # ViewComponent::TestCase, namespaced modules
system/ # ApplicationSystemTestCase
fabricators/ # Fabrication definitions
factories/ # FactoryBot definitions
test_helper.rb # Global config, stubs, helpers
Running Tests
Always run tests in parallel with code coverage enabled:
COVERAGE=1 PARALLEL=1 rails test # Full suite
COVERAGE=1 PARALLEL=1 rails test test/models/post_test.rb # Single file
COVERAGE=1 PARALLEL=1 rails test -n "test_method_name" # Single test by name
JavaScript Tests
After modifying any JavaScript or Stimulus controller, also run the JS test suite:
yarn test
Run both Ruby and JavaScript tests when changes span both languages:
COVERAGE=1 PARALLEL=1 rails test && yarn test
Post-Test Coverage Check
After running tests with COVERAGE=1, check for coverage/coverage.json in the project root. If it exists, read it to verify your tests adequately cover the code you changed.
How to check
-
Read the coverage file:
cat coverage/coverage.jsonThe file is a JSON object keyed by absolute file path. Each entry contains a
linesarray where:null= line not relevant (comments, blank lines,endkeywords)0= executable line not covered by any test1+= line covered (the number is how many times it was executed)
-
Filter to changed files only. Look up each
.rbfile you created or modified in this session. Ignore files you didn't touch. -
Calculate coverage for each file:
- Count relevant lines (non-
nullentries) - Count covered lines (entries
>= 1) - Coverage % = covered / relevant × 100
- Count relevant lines (non-
-
Evaluate against thresholds:
- ≥ 90% — Good. Move on.
- 70–89% — Review the uncovered lines. If they're meaningful code paths (conditionals, error handling, public methods), write additional tests to cover them.
- < 70% — Insufficient. Identify all uncovered methods and branches, then add tests until coverage reaches at least 90%.
-
Focus on what matters. Prioritize covering:
- Public methods with zero coverage
- Conditional branches (
if/else,case/when) where only one path is hit - Error handling and rescue blocks
- Guard clauses and early returns
If coverage/coverage.json does not exist, the project may not have SimpleCov configured. In that case, skip this check and rely on the manual test review in the pre-flight section.
Principles
- Test behavior, not implementation — assert on outcomes, not internal method calls (unless verifying side effects like job enqueues or service calls).
- Keep tests focused — aim for one assertion per test when practical, and never
exceed 4 assert statements in a single
itblock. If you need more, split into separate tests. This keeps failures easy to diagnose and test names meaningful. - Descriptive test names —
it "returns the host from a standard URL"notit "works". - Minimal setup — only fabricate what the test needs. Prefer
Fabricate.buildwhen persistence isn't required. - No flaky tests — avoid time-dependent assertions without freezing time. Use
assert_in_deltafor ranges. - Follow existing patterns — before writing a new test file, read 2-3 similar ones in the same directory.
- Never skip tests — do not use
skip,skip_if, or comment out test blocks. If a test is failing, fix the code or the test — don't skip it. Skipped tests create blind spots that hide real bugs.