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
- etewiah/property_web_buildertheir own version, first seen Oct 9, 2026
- marckohlbrugge/unofficial-37signals-coding-style-guidetheir own version, first seen Oct 8, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- marckohlbrugge/unofficial-37signals-coding-style-guide★ 724same name
- etewiah/property_web_builder★ 644same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository24 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- rails-activestorageSkill
- rails-apiSkill
- rails-auditSkill
- rails-clean-test-outputSkill
- rails-controllersSkill
- rails-coreSkill
- rails-database-performanceSkill
- rails-i18nSkill
- rails-jobsSkill
- rails-loggingSkill
- rails-mailersSkill
- rails-migrationsSkill
- rails-modelsSkill
- rails-multi-tenancySkill
- rails-performanceSkill
- rails-philosophySkill
- rails-project-setupSkill
- rails-securitySkill
- rails-stimulusSkill
- rails-styleSkill
- rails-turboSkill
- rails-upgradeSkill
- rails-viewcomponentsSkill
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
- Very specific: line 75
Use ApplicationSystemTestCase with Chrome/Selenium, headless by default, visible via env var.
- Very specific: line 335
Put domain-specific assertion helpers in test/test_helpers/ and include them in test_helper.rb.
- Very specific: line 382
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 …
- Distinctive: line 458
Run with coverage on, then read the JSON to judge the diff — not the whole app:
- Distinctive: line 479
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 …
- Very specific: line 484
Build, don't persist, when persistence isn't needed: Use Model.new/build for pure logic and validation tests.
- Distinctive: line 568
Before writing or finishing a change, scope what moved and confirm each piece has a test:
- Distinctive: line 583
If a changed file has no corresponding test, write one — new code ships with tests, no exceptions.
≈ “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 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
end2. 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/.lastbehave predictably), prepend a module intoActiveRecord::FixtureSet(viaActiveSupport.on_load(:active_record_fixture_set)) that overridesidentifyto emit UUIDv7s with past timestamps derived from the label:Zlib.crc32("fixtures/#{label}")milliseconds after a fixedTime.utc(2024, 1, 1)base. The same override can treat a_uuidlabel suffix as an implicit:uuidcolumn type. - A fixture with an explicit
id:breaks association-by-label references to it. Ifusers.ymladminsetsid: 1, thencreator: adminelsewhere writes the FK asFixtureSet.identify(:admin)— a hashed id that does not equal the explicit1— sorecord.creatorloadsnileven thoughcreator_idis 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
end4. 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
endInclude 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 state | Why it breaks | Fix |
Rails.cache | If 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 service | config/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 tests | Rails::Generators::TestCase subclasses declaring destination Rails.root.join("tmp/generators") share one directory, and prepare_destination wipes it. | Suffix destination_root per worker too. |
| SimpleCov | Workers 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))
endReading 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 Bindingmeans a dev gem is loaded in test.better_errors+binding_of_callerattach aBindingto exceptions, which cannot be marshalled, so every failure becomes an unreportable worker crash and the run neither passes nor finishes. Move them togroup :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
end7. 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
end8. 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
end9. 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
endKey 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 thefill_in_lexxyexecute_scriptworkaround (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
endKey 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"
endConfig 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
])
endRun with coverage on, then read the JSON to judge the diff — not the whole app:
COVERAGE=1 bin/rails testcoverage/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", nottest "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
skipor 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/buildfor pure logic and validation tests; only hit the database (fixtures orcreate!) 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_governorThe 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_pluginsbecame opt-in, so dropping aminitest/*_plugin.rbon the load path silently does nothing. To register a custom reporter, require it and push ontoMinitest.extensionsyourself fromtest_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
setupblock touched by the most tests gives the most return. A 50ms win on theusersfixture 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
endThis 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-onlyMap each changed app/**/*.rb to its test/**/*_test.rb counterpart:
| Changed file | Expected test |
app/models/post.rb | test/models/post_test.rb |
app/controllers/posts_controller.rb | test/controllers/posts_controller_test.rb |
app/jobs/analyze_post_job.rb | test/jobs/analyze_post_job_test.rb |
app/components/card_component.rb | test/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 ])
endKeep 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
| Command | Description |
bin/rails test | Run all unit/integration tests |
bin/rails test test/file.rb:42 | Run test at specific line |
bin/rails test:system | Run system tests |
bin/ci | Run full CI pipeline |
PARALLEL_WORKERS=1 bin/rails test | Disable parallel execution (debugging, system tests) — also the control run for confirming a parallel-only failure |
powerprofilesctl get | Rule out a throttled machine before profiling a "slow" suite (4.8x swing measured) |
SYSTEM_TESTS_BROWSER=true bin/rails test:system | See browser during tests |
VCR_RECORD=true bin/rails test | Record new VCR cassettes |
COVERAGE=1 bin/rails test | Run with SimpleCov; read coverage/coverage.json for the diff |
git diff main...HEAD --name-only | Scope 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/rspec | Stackprof-profile an inherited RSpec suite (test-prof) |
FPROF=flamegraph bin/rspec | Find factory cascades in an inherited factory-based suite (TestProf) |
| Pattern | When to Use |
ActiveRecord::FixtureSet.identify("name", :uuid) | Deterministic UUID fixture id |
account: 37s_uuid | Reference a UUID fixture |
creator_id: 1 (not creator: admin) | Reference a fixture that sets an explicit id: |
sign_in_as :user | Authenticate in integration tests |
Current.session = sessions(:david) | Set user session context |
include VcrTestHelper | Record 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: SomeJob | Assert 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) |
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.

