rails-cto-minitest
Skill15 starsChanged 7 months agoMITView on GitHub
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.
In this repository17 other files it gives its agents.
- claude/CLAUDE.mdCLAUDE.md
- rails-cto-apiSkill
- rails-cto-architectSkill
- rails-cto-commitSkill
- rails-cto-engineerSkill
- rails-cto-erbSkill
- rails-cto-production-prSkill
- rails-cto-pull-requestSkill
- rails-cto-qaSkill
- rails-cto-restfulSkill
- rails-cto-securitySkill
- rails-ctoSkill
- rails-cto-static-analysisSkill
- rails-cto-stimulusSkill
- rails-cto-tailwindSkill
- rails-upgradeSkill
- rails-viewcomponentsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
20 rules. About 0 are common, 7 are distinctive, and 13 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
13
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 19
Before running any tests, confirm that test/test_helper.rb configures SimpleCov to export JSON.
- Very specific: line 31
If JSONFormatter is missing, add it so that coverage/coverage.json is generated when tests run with COVERAGE=1.
- Distinctive: line 35
Before writing or running tests, understand what's changed on this branch.
- Distinctive: line 51
Check for missing tests: If a changed file has no corresponding test file, create one.
- Distinctive: line 53
Check coverage for existing tests: Run the tests for the changed files with coverage enabled:
- Very specific: line 67
Use describe, it, let, subject, and before blocks.
- Very specific: line 109
When different tests need different attributes on the primary object, use a let(:attributes) block that subject references.
- Very specific: line 177
If every test uses the same attributes, define subject directly without the let(:attributes) pattern:
- Very specific: line 201
If you are writing a test and have not defined subject at the top of the class, stop and add it before continuing.
- Very specific: line 205
Use assert-style exclusively.
- Very specific: line 258
Always define subject as an instance of the model being tested.
- Distinctive: line 326
Test HTTP verbs, response codes, and response body content.
- Very specific: line 468
Use WebMock to keep tests off the network.
- Very specific: line 470
Check test_helper.rb for the stubs already in place before writing new ones.
- Distinctive: line 506
Always run tests in parallel with code coverage enabled:
- Distinctive: line 516
After modifying any JavaScript or Stimulus controller, also run the JS test suite:
- Distinctive: line 522
Run both Ruby and JavaScript tests when changes span both languages:
- Very specific: line 530
After running tests with COVERAGE=1, check for coverage/coverage.json in the project root.
- Very specific: line 543
Filter to changed files only: Look up each .rb file you created or modified in this session.
- Very specific: line 561
If coverage/coverage.json does not exist, the project may not have SimpleCov configured.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
])
endIf 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:
This tells you which files were added or modified. Focus testing efforts on these files only.git diff staging...HEAD --stat git diff staging...HEAD --name-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:
Review 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:COVERAGE=1 PARALLEL=1 rails test test/path/to/relevant_test.rb- 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
endKey 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
endWhen 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
endIf 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 # inclusionTest 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
endFor 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
endCommand / 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
endController / 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
endTurbo 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
endViewComponent 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
endSystem 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
endMocking 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 ApplicationControllerFile 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, helpersRunning 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 nameJavaScript Tests
After modifying any JavaScript or Stimulus controller, also run the JS test suite:
yarn testRun both Ruby and JavaScript tests when changes span both languages:
COVERAGE=1 PARALLEL=1 rails test && yarn testPost-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:
The file is a JSON object keyed by absolute file path. Each entry contains acat coverage/coverage.jsonlinesarray 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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

