Write Rails tests following DHH/37signals testing philosophy, adapted for RSpec and FactoryBot — system specs as the backbone, real objects over mocks, behaviour-driven testing over implementation testing. Use when writing specs, adding test coverage, debugging test failures, creating factories, or when the user mentions tests, specs, RSpec, FactoryBot, Capybara, system tests, request specs, or test coverage.
| Task | Action |
|---|---|
| New feature end-to-end | Read references/system-specs.md, write a system spec |
| Model domain logic | Read references/model-specs.md, write a model spec |
| HTTP behaviour | Read references/request-specs.md, write a request spec |
| Creating test data | Read references/factory-patterns.md, create factories |
| Authorization / policy | Read references/support-specs.md § Policy Specs, write a policy spec |
| Job / concern | Read references/support-specs.md, write the appropriate spec |
| Mailer class, templates, previews | Read ../writing-mailers/references/patterns.md; RSpec examples in references/support-specs.md § Mailer Specs |
| I18n / asserting on translated copy | Read ../writing-i18n/references/patterns.md § Testing |
| Debugging a failing test | Read all relevant references, diagnose the failure |
| Adding coverage to existing code | Determine the right spec type, read the reference |
Is this a user-facing feature?
├── YES → System spec (Capybara, real browser)
│ User creates an article, user closes a card
└── NO
Is this about HTTP behaviour (status codes, redirects, auth)?
├── YES → Request spec
│ 401/redirect after login, CSRF protection
└── NO
Is this authorization logic (who can do what)?
├── YES → Policy spec
│ admin can delete, owner can edit, scope filters
└── NO
Is this domain logic on a model?
├── YES → Model spec
│ article.publish, scope queries, state transitions
└── NO
Is this a job, mailer, or standalone object?
├── YES → Spec matching the object type
│ Job enqueues, mailer sends, PORO processes
└── NO
Add the assertion to an existing system spec.
Every test follows this pattern:
RSpec.describe Article, type: :model do
describe "#publish" do
context "when the article is a draft" do
it "creates a publication record" do
# Arrange — set up the world
article = create(:article)
# Act — do the thing
article.publish
# Assert — verify the outcome
expect(article).to be_published
expect(article.publication).to be_present
end
end
context "when already published" do
it "raises AlreadyPublished" do
article = create(:article, :published)
expect { article.publish }.to raise_error(Article::AlreadyPublished)
end
end
end
end
Key principles:
let chains. A single let for the authenticated user is fine; five nested lets are not.describe "#method", context "when X", it "does Y"create(:article), not double or instance_doubleBefore writing a test, ask:
"Am I testing behaviour or implementation?"
"Do I need a mock here?"
travel_to)"Is this test pulling its weight?"
"Is this already tested elsewhere?"
article.publish works, the system spec just clicks
"Publish" and checks the page — it doesn't also inspect article.publication."Am I splitting tests unnecessarily within this file?"
context), not when you want to
assert another facet of the same outcome.expect lines,
merge them into one test."Where does this test live?"
spec/ mirroring app/ structurespec/system/ organized by featurespec/factories/ one file per modelEvery behaviour is tested in exactly one place. Duplication across spec types slows the suite, obscures what each layer proves, and creates maintenance drag when behaviour changes. Duplication within a file inflates test counts without adding confidence.
Each spec type has a job. Test the behaviour where it naturally lives, and trust the lower layer from above:
┌─────────────┐ Owns: user-visible flows, page content, form interactions
│ System spec │ Trusts: model logic, policies, HTTP layer
├─────────────┤ Owns: status codes, redirects, rate limits
│ Request spec│ Owns: auth gates — ALWAYS test auth here, even if system specs exist
│ │ Trusts: policy logic (tested in policy spec), model logic (tested in model spec)
│ │ Does NOT duplicate: system spec flows or policy logic
├─────────────┤ Owns: authorization logic — who can do what, scoped collections
│ Policy spec │ Is trusted by: request and system specs
│ │ Does NOT test: HTTP responses or UI
├─────────────┤ Owns: domain verbs, scopes, state transitions, business rules
│ Model spec │ Is trusted by: system, request, and policy specs
│ │ Does NOT test: HTTP, UI, or authorization concerns
├─────────────┤ Owns: the work the job/mailer performs
│ Support spec│ Callers assert enqueuing only (have_enqueued_job / have_enqueued_mail)
└─────────────┘
Concrete example — article publishing:
# Model spec — owns the domain logic
describe "#publish" do
it "creates a publication and records the publisher" do
article = create(:article)
article.publish
expect(article).to be_published
expect(article.publication.publisher).to eq(Current.user)
end
end
# System spec — owns the user flow, trusts the model
it "user publishes an article" do
visit article_path(article)
click_button "Publish"
expect(page).to have_content("Published") # visible outcome only
# Does NOT check article.publication.present? — model spec covers that
end
# Request spec — ONLY if there's an HTTP concern system spec can't cover
# e.g., specific status code, rate limiting, auth gate
# Do NOT write a request spec that just posts and checks the record exists
# when the system spec already covers the flow.
Don't split assertions about the same action into separate tests:
# Bad — three tests for one action, identical setup
it "publishes the article" do
article.publish
expect(article).to be_published
end
it "creates a publication record" do
article.publish
expect(article.publication).to be_present
end
it "records the publisher" do
article.publish
expect(article.publication.publisher).to eq(Current.user)
end
# Good — one test verifying one behaviour from multiple angles
it "publishes the article with attribution" do
article = create(:article)
article.publish
expect(article).to be_published
expect(article.publication).to be_present
expect(article.publication.publisher).to eq(Current.user)
end
Split into separate it blocks only when the context differs — different
preconditions, different user roles, different input. The signal for a new test
is a new context, not a new expect.
# Good — different contexts warrant separate tests
context "when the article is a draft" do
it "publishes successfully" do
article = create(:article)
article.publish
expect(article).to be_published
end
end
context "when already published" do
it "raises AlreadyPublished" do
article = create(:article, :published)
expect { article.publish }.to raise_error(Article::AlreadyPublished)
end
end
| Anti-Pattern | Instead |
|---|---|
| Mocking ActiveRecord models | Use FactoryBot — create real records |
allow_any_instance_of | Test through the real code path |
| Testing private methods directly | Test through public interface |
before(:all) for database records | before(:each) or inline create |
Deep let / subject chains that obscure setup | Inline setup in each test; a single let for auth user is fine |
| Shared examples across unrelated specs | Inline the assertion — clarity over DRY |
| Testing validates/belongs_to declarations | Test domain behaviour, not framework |
| Controller specs | Request specs — controller specs are deprecated |
stub_const for ENV vars | Use Rails credentials or test config |
| Asserting exact error messages | Assert error keys or behaviour |
| Giant setup blocks | Extract to factory traits |
is_expected.to with implicit subject | Explicit subject and expectation |
| Request spec that duplicates a system spec flow | Request spec only for HTTP-layer concerns (status, redirects, auth) |
System spec inspecting model internals (article.publication) | System spec asserts what the user sees on the page |
| Model spec + request spec + system spec for the same happy path | One home per behaviour — pick the right layer |
Separate it blocks for each assertion on one action | One it with multiple expects when setup and action are identical |
| Re-testing domain verb logic in a request spec | Request spec calls the endpoint; model spec owns the verb logic |
| Job spec re-testing what the model spec covers | Job spec tests orchestration; model spec tests the domain method the job calls |
Standalone not_to assertions to prove code was removed | Assert the positive behaviour the user sees; not_to is a side-effect, not a primary assertion |
A test whose only assertion is not_to have_* does not test behaviour — it tests absence. It passes trivially (including if the page is blank), documents nothing about what users can do, and breaks silently when an element is renamed rather than removed.
# Bad — only asserts an element is absent; no positive behaviour proven
it "does not show an excluded agencies section" do
expect(page).not_to have_link("New Excluded Agency")
end
# Good — asserts what the user actually sees and can do
it "shows the agencies list" do
visit agencies_path
expect(page).to have_content("Agencies")
expect(page).to have_link("New Agency")
end
not_to is valid as a secondary assertion confirming a visible change alongside a positive one:
# Fine — not_to confirms removal after deletion, paired with a positive assertion
it "user deletes an article" do
article = create(:article, title: "To Delete")
visit article_path(article)
click_button "Delete"
expect(page).to have_content("Article deleted.") # primary assertion
expect(page).not_to have_content("To Delete") # confirms removal
end
Every test must have at least one positive assertion. A test that consists only of not_to is not a test — it is a removal receipt.
| Thing | Convention | Examples |
|---|---|---|
| Spec files | _spec.rb suffix matching source | article_spec.rb, articles_spec.rb |
| Top-level describe | Class or feature name | RSpec.describe Article, RSpec.describe "Article management" |
| Method describes | #instance_method, .class_method | describe "#publish", describe ".search" |
| Contexts | Start with "when" or "with" | context "when published", context "with comments" |
| Examples | Read as sentences | it "creates a publication record" |
| Factories | Singular model name | factory :article, factory :user |
| Traits | Adjective or state | :published, :archived, :with_comments |
| System specs | User action or flow | "User publishes an article", "Admin manages users" |
Before finishing, verify:
it blocks — tests with identical setup/action are merged into onenot_to assertions — every test has at least one positive assertion; not_to is only used alongside a positive oneFor detailed patterns and examples by spec type: