agentleFS
mickzijdelmickzijdel/rails-toolkit/skills/rails-testing/SKILL.md

rails-testing

Skill2 starsChanged 8 days agoMITView on GitHub

Use when writing tests with fixtures, system tests, VCR cassettes, and parallel execution, or when profiling/speeding up a slow test suite (Stackprof, Speedscope, TestProf)

  • Reads credentials

2 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 4 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use ApplicationSystemTestCase with Chrome/Selenium, headless by default, visible via env var.

    line 75
  2. Very specific:

    Put domain-specific assertion helpers in test/test_helpers/ and include them in test_helper.rb.

    line 335
  3. Very specific:

    If an app translates admin index/search-form headers via simple_form labels, a new column used as a header or search field needs a …

    line 382
  4. Distinctive:

    Run with coverage on, then read the JSON to judge the diff — not the whole app:

    line 458
  5. Distinctive:

    One behaviour per test; ≤ 4 assertions: If a test needs more, it's testing more than one thing — split it so a failure name points at …

    line 479
  6. Very specific:

    Build, don't persist, when persistence isn't needed: Use Model.new/build for pure logic and validation tests.

    line 484
  7. Distinctive:

    Before writing or finishing a change, scope what moved and confirm each piece has a test:

    line 568
  8. Distinctive:

    If a changed file has no corresponding test, write one — new code ships with tests, no exceptions.

    line 583

≈ “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 Testing Patterns

1. Test Helper Setup

Configure the suite once in test/test_helper.rb: parallel workers, fixtures, helper modules, and Current setup/teardown.

# test/test_helper.rb
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"

require "rails/test_help"
require "webmock/minitest"
require "vcr"
require "mocha/minitest"
require "turbo/broadcastable/test_helper"

WebMock.allow_net_connect!

module ActiveSupport
  class TestCase
    parallelize workers: :number_of_processors, work_stealing: ENV["WORK_STEALING"] != "false"
    fixtures :all

    include ActiveJob::TestHelper
    include ActionTextTestHelper, CachingTestHelper, SessionTestHelper
    include Turbo::Broadcastable::TestHelper

    setup do
      Current.account = accounts("37s")
    end

    teardown do
      Current.clear_all   # prevent Current leaking between tests
    end
  end
end

2. Fixture Patterns with Deterministic UUIDs

Apps with UUID primary keys need deterministic fixture IDs for cross-references. Use ActiveRecord::FixtureSet.identify with :uuid, and reference other fixtures with the _uuid suffix.

# test/fixtures/accounts.yml
37s:
  id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %>
  name: 37signals
  external_account_id: <%= ActiveRecord::FixtureSet.identify("37signals") %>

# test/fixtures/users.yml
david:
  id: <%= ActiveRecord::FixtureSet.identify("david", :uuid) %>
  name: David
  identity: david        # non-UUID FK: plain fixture name
  account: 37s_uuid      # UUID FK: reference with _uuid suffix
  verified_at: <%= Time.current.to_fs(:db) %>

Key Points:

  • Stock identify(:uuid) is deterministic but unordered. To make fixtures also sort before runtime-created records (so .first/.last behave predictably), prepend a module into ActiveRecord::FixtureSet (via ActiveSupport.on_load(:active_record_fixture_set)) that overrides identify to emit UUIDv7s with past timestamps derived from the label: Zlib.crc32("fixtures/#{label}") milliseconds after a fixed Time.utc(2024, 1, 1) base. The same override can treat a _uuid label suffix as an implicit :uuid column type.
  • A fixture with an explicit id: breaks association-by-label references to it. If users.yml admin sets id: 1, then creator: admin elsewhere writes the FK as FixtureSet.identify(:admin) — a hashed id that does not equal the explicit 1 — so record.creator loads nil even though creator_id is set. Reference the explicit id directly (creator_id: 1), not the label.

3. System Tests with Capybara and Selenium

Use ApplicationSystemTestCase with Chrome/Selenium, headless by default, visible via env var.

# test/application_system_test_case.rb
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  browser_options = Selenium::WebDriver::Chrome::Options.new.tap do |opts|
    opts.add_argument("--window-size=1200,800")
    opts.add_argument("--disable-extensions")
    opts.add_argument("--deny-permission-prompts")
    opts.add_argument("--enable-automation")
  end

  Capybara.register_driver :chrome_headless do |app|
    browser_options.add_argument("--headless")
    Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options)
  end

  Capybara.register_driver :chrome do |app|
    Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options)
  end

  # SYSTEM_TESTS_BROWSER=true to watch the browser
  if ENV["SYSTEM_TESTS_BROWSER"]
    driven_by :chrome, screen_size: [ 1200, 1000 ]
  else
    driven_by :chrome_headless, screen_size: [ 1200, 1000 ]
  end
end
# test/system/smoke_test.rb
class SmokeTest < ApplicationSystemTestCase
  test "create a card" do
    sign_in_as(users(:david))

    visit board_url(boards(:writebook))
    click_on "Add a card"
    fill_in "card_title", with: "Hello, world!"
    fill_in_lexxy with: "I am editing this thing"
    click_on "Create card"

    assert_selector "h3", text: "Hello, world!"
  end

  private
    def sign_in_as(user)
      visit session_transfer_url(user.identity.transfer_id, script_name: nil)
      assert_selector "h1", text: "Latest Activity"
    end

    # Rich-text editors that read their value live need execute_script, not fill_in
    def fill_in_lexxy(selector = "lexxy-editor", with:)
      editor_element = find(selector)
      editor_element.set with
      page.execute_script("arguments[0].value = '#{with}'", editor_element)
    end
end

4. VCR Cassettes for HTTP Stubbing

Record external API calls (e.g. OpenAI) once, replay them in future runs.

# test/test_helper.rb
VCR.configure do |config|
  config.allow_http_connections_when_no_cassette = true
  config.cassette_library_dir = "test/vcr_cassettes"
  config.hook_into :webmock

  # Redact API keys from recordings
  config.filter_sensitive_data("<OPEN_AI_KEY>") {
    Rails.application.credentials.openai_api_key || ENV["OPEN_AI_API_KEY"]
  }

  # Ignore timestamps in request bodies for matching
  config.before_record do |i|
    if i.request&.body
      i.request.body.gsub!(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "<TIME>")
    end
  end

  config.register_request_matcher :body_without_times do |r1, r2|
    b1 = (r1.body || "").gsub(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "<TIME>")
    b2 = (r2.body || "").gsub(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "<TIME>")
    b1 == b2
  end

  config.default_cassette_options = {
    match_requests_on: [ :method, :uri, :body_without_times ]
  }
end
# test/test_helpers/vcr_test_helper.rb
module VcrTestHelper
  extend ActiveSupport::Concern

  included do
    class_attribute :vcr_record

    setup do
      @casette_name = "#{self.class.name.tableize.singularize}-#{name}"
      VCR.insert_cassette @casette_name,
        record: recording? ? :all : :none,
        preserve_exact_body_bytes: true
    end

    teardown do
      VCR.eject_cassette
    end

    def recording?
      vcr_record || ENV["VCR_RECORD"]
    end
  end

  class_methods do
    def vcr_record!
      raise "#vcr_record! is meant for dev time. You are not supposed to run it in CI." if ENV["CI"]
      self.vcr_record = true
    end
  end
end

Include VcrTestHelper in tests that hit HTTP; record new cassettes with VCR_RECORD=true (or a temporary vcr_record! in the class).


5. Parallel Test Execution

Unit/integration tests parallelize across CPUs (Pattern 1); system tests must run with PARALLEL_WORKERS=1 — they can't run reliably in parallel. Also use PARALLEL_WORKERS=1 when debugging flaky tests.

Adding parallelize to an existing suite is not a one-line change. Rails gives each worker its own database, and nothing else. Every other piece of global state stays shared, so a suite that has only ever run single-process has usually grown dependencies on that. Audit these four before you flip it on — measured on a real 3027-test suite where every one of them bit:

Shared stateWhy it breaksFix
Rails.cacheIf config/environments/test.rb never sets cache_store, Rails falls back to a FileStore on tmp/cache/ — one directory, shared by every worker, and never rolled back between tests. Rate limiters, fetch-memoised lookups and dedupe keys then collide across workers.config.cache_store = :memory_store (per-process, and it also drops a disk round-trip from every cache call in serial runs). Use :null_store only if no test round-trips the cache.
ActiveStorage disk serviceconfig/storage.yml's test: service roots at a single tmp/storage. A teardown that does FileUtils.rm_rf(Rails.root.join("tmp", "storage")) will delete another worker's blobs mid-test.Suffix the root per worker in parallelize_setup, and make the teardown remove that root, not the hardcoded path.
Generator testsRails::Generators::TestCase subclasses declaring destination Rails.root.join("tmp/generators") share one directory, and prepare_destination wipes it.Suffix destination_root per worker too.
SimpleCovWorkers overwrite each other's results.Per-worker command_name + merge in parallelize_teardown.
# test/test_helper.rb
parallelize(workers: :number_of_processors)

parallelize_setup do |worker|
  svc = ActiveStorage::Blob.service
  svc.root = "#{svc.root}-#{worker}" if svc.respond_to?(:root=)

  Rails::Generators::TestCase.descendants.each do |klass|
    klass.destination_root = "#{klass.destination_root}-#{worker}"
  end
end

teardown do
  # NOT the hardcoded tmp/storage — that is another worker's data
  FileUtils.rm_rf(ActiveStorage::Blob.service.try(:root))
end

Reading the failures: DRb errors are noise, not the bug. Workers ship results to the parent over DRb, so an isolation failure usually surfaces as a wall of DRb::DRbConnError / DRb::DRbServerNotFound stack traces with no test name. Two rules:

  • Find the one real error underneath. Grep the log for Neutered Exception, Error:, or a #test_ name and ignore the DRb frames — Rails wraps unmarshalable failures (safe_record) and the wrapper is what you're seeing.
  • no _dump_data is defined for class Binding means a dev gem is loaded in test. better_errors + binding_of_caller attach a Binding to exceptions, which cannot be marshalled, so every failure becomes an unreportable worker crash and the run neither passes nor finishes. Move them to group :development — they were never meant to be in :development, :test. (This is the modern, concrete reason to keep dev-only gems out of the test group.)

Not everything is fork-safe. A helper that invokes real Rake tasks (Rails.application.load_tasks + Rake::Task#invoke) can deadlock in a forked worker with ThreadError: deadlock; recursive locking while passing perfectly under PARALLEL_WORKERS=1. Confirm any suspected parallel-only failure by re-running the same file both ways before you go hunting.

Sharding across multiple CI jobs: when a suite outgrows one machine, split it with a queue, never a static file list. Hardcoded slices ("job 3 runs test/system/a*–test/system/m*") always drift unbalanced, and the build is only as fast as its unluckiest job. With a queue, every worker pulls the next test file as it finishes, so all shards end at roughly the same time (the same work-stealing principle parallelize already applies in-process). Tools: test-queue, parallel_tests (runtime-based balancing), spec-wrk (networked queue across GitHub Actions jobs), or paid services like Knapsack Pro.


6. Current Context in Tests

Current.account is set globally in setup (Pattern 1); set Current.session = sessions(:david) when a test needs a logged-in user, and always Current.clear_all in teardown.

# test/test_helpers/session_test_helper.rb — temporary user context
module SessionTestHelper
  def with_current_user(user)
    user = users(user) unless user.is_a? User
    @old_session = Current.session
    begin
      Current.session = Session.new(identity: user.identity)
      yield
    ensure
      Current.session = @old_session
    end
  end
end
# URL-tenanted apps: set the account prefix for generated URLs
class ActionDispatch::IntegrationTest
  setup do
    integration_session.default_url_options[:script_name] = "/#{ActiveRecord::FixtureSet.identify("37signals")}"
  end
end

7. Integration Test Authentication

A sign_in_as helper drives the real (magic link) authentication flow; tests then exercise protected endpoints normally.

# test/test_helpers/session_test_helper.rb
module SessionTestHelper
  def sign_in_as(identity)
    cookies.delete :session_token

    if identity.is_a?(User)
      user = identity
      identity = user.identity
      raise "User #{user.name} doesn't have an associated identity" unless identity
    elsif !identity.is_a?(Identity)
      identity = identities(identity)
    end

    identity.send_magic_link
    magic_link = identity.magic_links.order(id: :desc).first

    untenanted do
      post session_path, params: { email_address: identity.email_address }
      post session_magic_link_url, params: { code: magic_link.code }
    end

    assert_response :redirect, "Magic Link code should grant access"
    assert_not_nil cookies.get_cookie("session_token"), "Expected session_token cookie"
  end

  def logout_and_sign_in_as(identity)
    Session.delete_all
    sign_in_as identity
  end

  # Temporarily drop the account URL prefix
  def untenanted(&block)
    original_script_name = integration_session.default_url_options[:script_name]
    integration_session.default_url_options[:script_name] = ""
    yield
  ensure
    integration_session.default_url_options[:script_name] = original_script_name
  end
end

8. Test Assertions and Helpers

Put domain-specific assertion helpers in test/test_helpers/ and include them in test_helper.rb. Use assert_difference with lambdas (and hashes for multiple counts), assert_turbo_stream for Turbo responses.

test "assignment toggling" do
  assert_difference({
    -> { cards(:logo).assignees.count } => -1,
    -> { Event.count } => +1
  }) do
    cards(:logo).toggle_assignment users(:kevin)
  end
end

9. i18n-Customised Errors & Editors You Can't fill_in

Two recurring test traps — asserting on Rails' default validation strings when the app has customised them, and trying to drive a rich-text/contenteditable field with fill_in.

# Validation messages are often i18n-customised (e.g. presence reads
# "must not be blank." not Rails' default "can't be blank"). Don't hard-code
# the default literal — assert presence, or match the configured message.
test "title is required" do
  opportunity = Opportunity.new(title: nil)
  assert_not opportunity.valid?
  assert opportunity.errors[:title].present?              # robust
end

# A markdown/contenteditable editor syncs its hidden textarea ON SUBMIT,
# overwriting anything Capybara/Playwright `fill_in`/`fill` injected — so a
# browser submit re-renders with a blank-field error. Cover via a request test:
class OpportunitiesControllerTest < ActionDispatch::IntegrationTest
  test "create with a description" do
    assert_difference -> { Opportunity.count }, +1 do
      post opportunities_path, params: {
        opportunity: { title: "Stage Manager", description: "# Role\nDetails here" }
      }
    end
    assert_equal "# Role\nDetails here", Opportunity.last.description
  end
end

Key Points:

  • Editors that sync on submit can't be driven by fill_in; the injected value is overwritten. Use request-level tests for the persistence path. This complements the fill_in_lexxy execute_script workaround (Pattern 3), which handles editors that read their value live.
  • Other Stimulus interactions on the same form (nested-form Add/Remove, toggles) still verify fine in system tests.
  • If an app translates admin index/search-form headers via simple_form labels, a new column used as a header or search field needs a simple_form.labels.defaults.<key> entry, or the page raises "Translation missing".

10. Background Jobs in Tests: :test Adapter, Not :inline

config.active_job.queue_adapter = :inline in the test environment (or Resque.inline = true) executes every enqueued job synchronously, everywhere. Every test implicitly runs background work it never asked for: state changes appear "by magic", and the suite burns time on side-effects no assertion needs. Keep the :test adapter (the Rails default) and drain jobs explicitly, only in tests that need the job's effects.

# ❌ Bad: config/environments/test.rb
config.active_job.queue_adapter = :inline   # every test runs every job

# ✅ Good: keep the :test adapter, drain explicitly
class ExportTest < ActiveSupport::TestCase
  test "completed export attaches a file" do
    export = accounts("37s").exports.create!

    perform_enqueued_jobs do        # Act: run the job this test is about
      export.build_later
    end

    assert export.reload.file.attached?
  end

  test "creating an export enqueues the build" do
    assert_enqueued_with job: ExportAccountDataJob do
      accounts("37s").exports.create!.build_later
    end
  end
end

Key Points:

  • Most tests should only assert the job was enqueued — that's the unit boundary; the job's behaviour gets its own test.
  • perform_enqueued_jobs(only: SomeJob) scopes draining when setup enqueues unrelated jobs.
  • Inheriting a suite built on :inline? Migrate gradually: switch the adapter, then fix tests that relied on implicit execution by adding explicit drains.

11. Mocking — Verify the Tools Exist Before Stubbing

Writing .stubs/.stub and getting NoMethodError: undefined method 'stubs' means the suite has no mocking library. minitest 6 dropped the bundled minitest/mock, and many suites never added mocha, so neither Object#stub nor .stubs can be assumed (the Pattern 1 example requires mocha/minitest — verify it's actually there). Prefer stubbing external services by toggling their configuration over introducing a mocking library:

# Force a reCAPTCHA failure without any mocking library: drop "test" from the
# skip list and send no token — verification really runs and really fails.
test "rejects submission when reCAPTCHA fails" do
  Recaptcha.configuration.skip_verify_env.delete("test")

  post opportunities_path, params: { opportunity: { title: "Stage Manager" } }

  assert_response :unprocessable_entity
ensure
  Recaptcha.configuration.skip_verify_env << "test"
end

Config toggles exercise the real code path; mocks only assert you called what you stubbed. For HTTP, use VCR/WebMock (Pattern 4) rather than stubbing the client class.


12. Coverage — Check What You Changed Is Actually Tested

SimpleCov tells you which lines your tests touched. Wire it to emit JSON so a coverage check is scriptable, not just a browseable HTML report. Add simplecov_json_formatter to the test group and register both formatters:

# test/test_helper.rb (top, before any app code is required)
require "simplecov"
SimpleCov.start "rails" do
  formatter SimpleCov::Formatter::MultiFormatter.new([
    SimpleCov::Formatter::HTMLFormatter,
    SimpleCov::Formatter::JSONFormatter
  ])
end

Run with coverage on, then read the JSON to judge the diff — not the whole app:

COVERAGE=1 bin/rails test

coverage/coverage.json is keyed by absolute file path; each entry's lines array holds one value per source line — null (not executable: blanks, comments, end), 0 (executable but never hit), or 1+ (hit count). For each file you changed: coverage % = (lines >= 1) / (non-null lines).

Thresholds — judge the changed files, not the global number:

  • ≥ 90% — good, move on.
  • 70–89% — review the uncovered lines; cover the meaningful ones.
  • < 70% — insufficient; add tests until the changed file is well covered.

Prioritise uncovered public methods, conditional branches (only one side of an if/case exercised), guard clauses / early returns, and rescue paths. A high global percentage hides an untested method you just wrote — always filter to the files in your diff. See [[rails-audit]] for using SimpleCov to quantify suite-wide coverage on an inherited app.


13. Testing Principles

Defaults that keep a fixtures-based suite fast to read and quick to diagnose:

  • One behaviour per test; ≤ 4 assertions. If a test needs more, it's testing more than one thing — split it so a failure name points at exactly what broke.
  • Descriptive names. test "returns the host from a standard URL", not test "host works". The name is the failure message.
  • Test behaviour, not implementation. Assert on outcomes (return values, persisted state, enqueued jobs), not on which private methods were called — unless the side effect is the contract (Pattern 10's assert_enqueued_with).
  • Never skip or comment out a test. A skipped test is a blind spot that reads as green. Fix the code or the test.
  • Read 2–3 neighbouring tests first. Match the conventions already in test/models/, test/controllers/, etc. before adding a new file.
  • Build, don't persist, when persistence isn't needed. Use Model.new/build for pure logic and validation tests; only hit the database (fixtures or create!) when the behaviour needs a saved record. See [[rails-core]] Rule 1 — extend fixtures, never mutate existing ones.

14. Profiling & Speeding Up a Slow Suite

A slow suite is a measurement problem before it's an optimisation problem. Profile first; optimise the few things that dominate; stop when the return drops.

Rule out the machine before you touch the suite. A laptop on a power-saving governor throttles hard, and nothing in the profile will point at it — every test just looks uniformly slow. On one measured suite, the identical 3027 tests took 560s on power-saver and 116s on performance: a 4.8x swing with no code change at all. Check first:

powerprofilesctl get     # or: cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

The tell is a local run slower than CI — CI runners are shared 2-4 core boxes, so if your dev machine loses to one, suspect the machine, not the suite. Don't trust /proc/cpuinfo MHz or scaling_cur_freq to confirm it; under intel_pstate/HWP they read implausibly low (~500 MHz) even on a fast machine. Time a real workload instead.

Then ask whether the cost is concentrated or diffuse — it decides which lever can possibly work:

  • Concentrated (a handful of files or tests dominate) → targeted fixes pay. Profile with Stackprof below.
  • Diffuse (a flat distribution — no file over ~5%, median close to mean) → there is no hot spot, and per-test micro-optimisation is the "four hours to shave one second" trap. Parallelism is the only lever with a real multiplier (Pattern 5).

A cheap way to tell, with no extra gems: record each test's time and aggregate by file. On the suite above — median 82ms, p90 479ms, slowest file 6.5%, and the ~1600 tests under 100ms adding up to only 9% of the runtime — the answer was plainly "diffuse", and parallelize took it from 116s to 27.7s on 8 workers. That is a 20x total against where it started, and none of it came from editing a test.

Minitest 6 no longer auto-loads plugins. Minitest.load_plugins became opt-in, so dropping a minitest/*_plugin.rb on the load path silently does nothing. To register a custom reporter, require it and push onto Minitest.extensions yourself from test_helper.rb.

Find the bottleneck (framework-agnostic). Stackprof samples the call stack while the suite runs; Speedscope turns the dump into a flamegraph. This works on a fixtures-based Minitest suite, not just RSpec.

# Minitest: wrap a representative run (one slow file, or the whole suite)
StackProf.run(mode: :wall, out: "tmp/stackprof.dump", raw: true) do
  # the test run — e.g. require + run the files, or profile inside a setup hook
end
# RSpec with test-prof's Stackprof integration:
TEST_STACK_PROF=1 SAMPLE=1000 bin/rspec
# then open tmp/stackprof-*.json (or the .dump) at https://www.speedscope.app/

Open the dump in Speedscope and use the Sandwich view — it ranks frames by total time, so the suite's real cost (a factory cascade, an over-eager callback, an unmemoised lookup) surfaces at the top.

Mindset:

  • Baseline first. Capture the suite's time before and after every change — optimisation you didn't measure is a guess.
  • Optimise the highest-impact shared thing. The fixture, factory, or setup block touched by the most tests gives the most return. A 50ms win on the users fixture beats a 2s win on one rarely-run file.
  • Stop when the return drops. As the article puts it: if it's taking four hours to shave one second off the suite, reconsider your priorities.

Disable expensive callbacks in tests by default (opt in per-test). The single most reusable idea here: expensive Active Record callbacks — history/audit tracking, denormalised counters, external pushes — fire on almost every saved record but are asserted on by ~1% of tests. Give the behaviour a Testing module with explicit toggles, switch it off globally in test_helper.rb, and turn it on only in the handful of tests that test the callback itself.

# app/models/concerns/history/testing.rb
module History::Testing
  def fake!  = Thread.current[:history_real] = false   # default in tests
  def real!  = Thread.current[:history_real] = true
  def faking? = !Thread.current[:history_real]
end

# the callback no-ops while faking
after_save :record_history, unless: -> { History.faking? }
# test/test_helper.rb — off by default
setup { History.fake! }
teardown { History.real! }

# only where the side effect IS the contract:
test "saving a card records history" do
  History.real!
  cards(:logo).update!(title: "Renamed")
  assert_equal 1, cards(:logo).history_entries.count
end

This is the same instinct as Pattern 10 (drain jobs explicitly, never the :inline adapter) and Pattern 11 (config toggles over mocks): the default test path does the least work that still proves the unit, and you opt into the expensive path only where it's under test. See [[rails-models]] for the callback patterns themselves.

Inherited a factory-based RSpec suite? Our suites are fixtures-first, and fixtures already load once and are shared across the whole suite — which is exactly what FactoryBot helpers like let_it_be, before_all, and create_default reinvent. Don't add them to a fixtures suite (and don't reach for a database-cleaner gem — fixtures wrap each test in a transaction already). But when you inherit a factory-based suite you can't convert, TestProf is the toolbox: RSpecDissect shows time spent in let/before, FactoryProf (FPROF=flamegraph bin/rspec) finds factory cascades, and let_it_be/AnyFixture share data across a file. Migrate the slowest files first, measuring each one.

Source: Evil Martians — "Railing against time".


15. Test-Gap Pre-flight — New Code Ships With Tests

Before writing or finishing a change, scope what moved and confirm each piece has a test:

git diff main...HEAD --name-only

Map each changed app/**/*.rb to its test/**/*_test.rb counterpart:

Changed fileExpected test
app/models/post.rbtest/models/post_test.rb
app/controllers/posts_controller.rbtest/controllers/posts_controller_test.rb
app/jobs/analyze_post_job.rbtest/jobs/analyze_post_job_test.rb
app/components/card_component.rbtest/components/card_component_test.rb

If a changed file has no corresponding test, write one — new code ships with tests, no exceptions. This pairs with [[rails-core]] Rule 8: after any fixture or factory change, run the full suite (PARALLEL_WORKERS=1 for readable output), since fixtures cascade across the whole suite.


16. One-line Declaration Checks with shoulda-matchers (optional)

shoulda-matchers collapses the boilerplate of asserting a model's declarations — validations, associations, enums — into one-liners. Under Minitest it pairs with shoulda-context, which supplies the should class macro.

# Gemfile
group :test do
  gem "shoulda-matchers"
  gem "shoulda-context"   # supplies the `should` macro under Minitest
end

# test/test_helper.rb (after Rails is loaded — see Pattern 1)
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :minitest
    with.library :rails
  end
end
# test/models/post_test.rb
class PostTest < ActiveSupport::TestCase
  should validate_presence_of(:title)
  should belong_to(:account)
  should have_many(:comments).dependent(:destroy)
  should define_enum_for(:status).with_values(%w[ draft published ])
end

Keep it to declarations. A matcher proves the declaration exists; it doesn't exercise what the model does. should validate_presence_of(:title) restates the validation — it never drives a blank title through a real request. For anything conditional (custom validators, state machines, composed scopes), write the behaviour test (Pattern 13). A wall of matchers is not a substitute for testing behaviour; it's optional sugar for the wiring you'd otherwise restate by hand.


Quick Reference

CommandDescription
bin/rails testRun all unit/integration tests
bin/rails test test/file.rb:42Run test at specific line
bin/rails test:systemRun system tests
bin/ciRun full CI pipeline
PARALLEL_WORKERS=1 bin/rails testDisable parallel execution (debugging, system tests) — also the control run for confirming a parallel-only failure
powerprofilesctl getRule out a throttled machine before profiling a "slow" suite (4.8x swing measured)
SYSTEM_TESTS_BROWSER=true bin/rails test:systemSee browser during tests
VCR_RECORD=true bin/rails testRecord new VCR cassettes
COVERAGE=1 bin/rails testRun with SimpleCov; read coverage/coverage.json for the diff
git diff main...HEAD --name-onlyScope changed files → map each to its *_test.rb
StackProf.run(mode: :wall, out: …) { … }Profile a slow suite; open the dump in Speedscope (Sandwich view)
TEST_STACK_PROF=1 SAMPLE=1000 bin/rspecStackprof-profile an inherited RSpec suite (test-prof)
FPROF=flamegraph bin/rspecFind factory cascades in an inherited factory-based suite (TestProf)
PatternWhen to Use
ActiveRecord::FixtureSet.identify("name", :uuid)Deterministic UUID fixture id
account: 37s_uuidReference a UUID fixture
creator_id: 1 (not creator: admin)Reference a fixture that sets an explicit id:
sign_in_as :userAuthenticate in integration tests
Current.session = sessions(:david)Set user session context
include VcrTestHelperRecord external HTTP calls
assert errors[:field].present?Assert validation failure without the literal i18n message
post create_path, params: {...}Test forms whose editor can't be fill_in-ed
perform_enqueued_jobs { ... }Explicitly run jobs (never :inline adapter)
assert_enqueued_with job: SomeJobAssert enqueueing without running the job
Config toggle (not .stubs)Stub external services when the suite has no mocking library
should validate_presence_of(:x)One-line model declaration check (shoulda-matchers; keep behaviour in real tests)

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.