agentleFS
mattsearsmattsears/rails-cto/skills/rails-cto-minitest/SKILL.md

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.

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
  1. Very specific:

    Before running any tests, confirm that test/test_helper.rb configures SimpleCov to export JSON.

    line 19
  2. Very specific:

    If JSONFormatter is missing, add it so that coverage/coverage.json is generated when tests run with COVERAGE=1.

    line 31
  3. Distinctive:

    Before writing or running tests, understand what's changed on this branch.

    line 35
  4. Distinctive:

    Check for missing tests: If a changed file has no corresponding test file, create one.

    line 51
  5. Distinctive:

    Check coverage for existing tests: Run the tests for the changed files with coverage enabled:

    line 53
  6. Very specific:

    Use describe, it, let, subject, and before blocks.

    line 67
  7. Very specific:

    When different tests need different attributes on the primary object, use a let(:attributes) block that subject references.

    line 109
  8. Very specific:

    If every test uses the same attributes, define subject directly without the let(:attributes) pattern:

    line 177
  9. Very specific:

    If you are writing a test and have not defined subject at the top of the class, stop and add it before continuing.

    line 201
  10. Very specific:

    Use assert-style exclusively.

    line 205
  11. Very specific:

    Always define subject as an instance of the model being tested.

    line 258
  12. Distinctive:

    Test HTTP verbs, response codes, and response body content.

    line 326
  13. Very specific:

    Use WebMock to keep tests off the network.

    line 468
  14. Very specific:

    Check test_helper.rb for the stubs already in place before writing new ones.

    line 470
  15. Distinctive:

    Always run tests in parallel with code coverage enabled:

    line 506
  16. Distinctive:

    After modifying any JavaScript or Stimulus controller, also run the JS test suite:

    line 516
  17. Distinctive:

    Run both Ruby and JavaScript tests when changes span both languages:

    line 522
  18. Very specific:

    After running tests with COVERAGE=1, check for coverage/coverage.json in the project root.

    line 530
  19. Very specific:

    Filter to changed files only: Look up each .rb file you created or modified in this session.

    line 543
  20. Very specific:

    If coverage/coverage.json does not exist, the project may not have SimpleCov configured.

    line 561

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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.

  1. Scope the changes:
    git diff staging...HEAD --stat
    git diff staging...HEAD --name-only
    This tells you which files were added or modified. Focus testing efforts on these files only.
  2. Map changed files to test files. For each changed .rb file, find its test counterpart:
    • app/models/post.rb → test/models/post_test.rb
    • app/controllers/posts_controller.rb → test/controllers/posts_controller_test.rb
    • app/commands/posts/create.rb → test/commands/posts/create_test.rb
    • app/jobs/analyze_post_job.rb → test/jobs/analyze_post_job_test.rb
    • app/components/forms/combo_select.rb → test/components/forms/combo_select_test.rb
  3. Check for missing tests. If a changed file has no corresponding test file, create one. New code ships with tests — no exceptions.
  4. 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.rb
    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:
    • 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
  5. 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

  • let blocks — lazy-loaded supporting data (accounts, parent records, config). Reserve let for dependencies, not the primary object being tested.
  • before blocks — setup that runs before each test in the enclosing describe.
  • describe blocks — group by method ("#instance_method", ".class_method") or behavior ("when logged in", "with invalid input").
  • it blocks — 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 → subject is an instance of the model
  • Command/service test → subject is an instance of the command's input record
  • Controller test → subject is the authenticated account
  • Job test → subject is the job instance or the record being processed
  • Component test → subject is 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

  1. Read the coverage file:
    cat coverage/coverage.json
    The file is a JSON object keyed by absolute file path. Each entry contains a lines array where:
    • null = line not relevant (comments, blank lines, end keywords)
    • 0 = executable line not covered by any test
    • 1+ = line covered (the number is how many times it was executed)
  2. Filter to changed files only. Look up each .rb file you created or modified in this session. Ignore files you didn't touch.
  3. Calculate coverage for each file:
    • Count relevant lines (non-null entries)
    • Count covered lines (entries >= 1)
    • Coverage % = covered / relevant × 100
  4. 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%.
  5. 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

  1. Test behavior, not implementation — assert on outcomes, not internal method calls (unless verifying side effects like job enqueues or service calls).
  2. Keep tests focused — aim for one assertion per test when practical, and never exceed 4 assert statements in a single it block. If you need more, split into separate tests. This keeps failures easy to diagnose and test names meaningful.
  3. Descriptive test names — it "returns the host from a standard URL" not it "works".
  4. Minimal setup — only fabricate what the test needs. Prefer Fabricate.build when persistence isn't required.
  5. No flaky tests — avoid time-dependent assertions without freezing time. Use assert_in_delta for ranges.
  6. Follow existing patterns — before writing a new test file, read 2-3 similar ones in the same directory.
  7. 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.