
Rails Testing
- 232 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
rails-testing: A skill for development. This provides functionality for development workflows.
Key points
- rails-testing
Rails Testing by the numbers
- 232 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,651 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill rails-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 232 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use rails-testing for development tasks?
Use rails-testing for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with rails-testing.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use rails-testing for development tasks, or when rails-testing: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to rails-testing: rails-testing.
Files
Community Ruby on Rails Testing Best Practices
Comprehensive testing guide for Ruby on Rails applications, maintained by Community. Contains 46 rules across 8 categories, prioritized by impact to guide automated test generation, review, and refactoring.
When to Apply
Reference these guidelines when:
- Writing new RSpec specs for models, requests, system tests, or jobs
- Setting up FactoryBot factories with traits and sequences
- Writing Capybara system tests for user journeys
- Testing background jobs with Sidekiq or Active Job
- Reviewing test code for anti-patterns (mystery guests, flaky tests, slow specs)
- Optimizing test suite performance and CI pipeline speed
- Organizing test files, shared examples, and custom matchers
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Test Design & Structure | CRITICAL | design- |
| 2 | Test Data Management | CRITICAL | data- |
| 3 | Model Testing | HIGH | model- |
| 4 | Request & Controller Testing | HIGH | request- |
| 5 | System & Acceptance Testing | MEDIUM-HIGH | system- |
| 6 | Async & Background Job Testing | MEDIUM | async- |
| 7 | Test Performance & Reliability | MEDIUM | perf- |
| 8 | Test Organization & Maintenance | LOW-MEDIUM | org- |
Quick Reference
1. Test Design & Structure (CRITICAL)
- `design-four-phase-test` - Use four-phase test structure (setup, exercise, verify, teardown)
- `design-behavior-over-implementation` - Test observable behavior, not internal implementation
- `design-one-assertion-per-test` - One logical expectation per test for precise failure diagnosis
- `design-descriptive-test-names` - Write test names that read like specifications
- `design-avoid-mystery-guest` - Make all test data visible within the test itself
- `design-avoid-conditional-logic` - No if/else or loops in test code
- `design-explicit-subject` - Name subjects explicitly instead of using implicit subject
2. Test Data Management (CRITICAL)
- `data-factory-traits` - Use composable factory traits instead of separate factories
- `data-minimal-attributes` - Specify only attributes relevant to the test
- `data-build-over-create` - Prefer build/build_stubbed over create when persistence isn't needed
- `data-avoid-fixture-coupling` - Use factories instead of shared fixtures
- `data-transient-attributes` - Use transient attributes for complex factory setup
- `data-sequence-unique-values` - Use sequences for uniqueness-constrained fields
3. Model Testing (HIGH)
- `model-test-validations` - Test validations with boundary cases, not just happy path
- `model-test-associations` - Test associations explicitly including dependent behavior
- `model-test-scopes` - Test scopes with matching and non-matching records
- `model-test-callbacks-sparingly` - Test callback side effects, not callback existence
- `model-test-custom-methods` - Test public methods with input/output pairs across scenarios
- `model-avoid-testing-framework` - Don't test ActiveRecord or framework behavior
- `model-test-enums` - Test enum transitions and generated scopes
4. Request & Controller Testing (HIGH)
- `request-over-controller-specs` - Use request specs over deprecated controller specs
- `request-test-response-status` - Assert HTTP status codes explicitly
- `request-test-authentication` - Test authentication boundaries for every protected endpoint
- `request-test-authorization` - Test authorization for each role
- `request-test-params-validation` - Test parameter validation and edge cases
- `request-json-response-structure` - Assert JSON response structure for API endpoints
5. System & Acceptance Testing (MEDIUM-HIGH)
- `system-page-objects` - Encapsulate page interactions in page objects
- `system-use-accessible-selectors` - Use accessible selectors over CSS/XPath
- `system-avoid-sleep` - Never use sleep — rely on Capybara's built-in waiting
- `system-test-critical-paths` - Reserve system tests for critical user journeys
- `system-database-state` - Use truncation strategy for system test database cleanup
- `system-screenshot-on-failure` - Capture screenshots on system test failure
6. Async & Background Job Testing (MEDIUM)
- `async-separate-enqueue-from-perform` - Test enqueue and perform separately
- `async-use-fake-mode-default` - Default to Sidekiq fake mode globally
- `async-test-job-perform` - Test job perform method directly
- `async-test-mailer-delivery` - Test mailer delivery with enqueued mail matcher
- `async-test-after-commit` - Account for transaction-aware job enqueuing in Rails 7.2+
7. Test Performance & Reliability (MEDIUM)
- `perf-parallel-tests` - Run tests in parallel across CPU cores
- `perf-database-strategy` - Use transaction strategy for non-system tests
- `perf-profile-slow-specs` - Profile and fix the slowest specs
- `perf-quarantine-flaky-tests` - Quarantine flaky tests instead of retrying
- `perf-avoid-before-all-mutation` - Never mutate state created in before(:all)
8. Test Organization & Maintenance (LOW-MEDIUM)
- `org-avoid-deep-nesting` - Limit context nesting to 3 levels
- `org-shared-examples-sparingly` - Use shared examples only for true behavioral contracts
- `org-custom-matchers` - Extract custom matchers for repeated domain assertions
- `org-file-structure-mirrors-app` - Mirror app directory structure in spec directory
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rails Testing
This curated skill mirrors SKILL.md. When maintaining it, keep the guidance focused on RSpec, factories, request/model/system specs, Capybara, Sidekiq jobs, and test-suite performance.
Rule Title Here
1-3 sentences explaining WHY this matters for test quality, reliability, or maintainability.
Incorrect (what's wrong):
# Bad example - production-realistic, not strawmanCorrect (what's right):
# Good example - minimal diff from incorrectWhen NOT to use this pattern:
- Exception 1
- Exception 2
Reference: Reference Title
{
"version": "1.0.6",
"organization": "Community",
"technology": "Ruby on Rails Testing",
"date": "February 2026",
"abstract": "Comprehensive testing best practices guide for Ruby on Rails applications, designed for AI agents and LLMs. Contains 46 rules across 8 categories, prioritized by impact from critical (test design, data management) to incremental (test organization). Each rule includes detailed explanations, real-world RSpec examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated test generation and review. Complementary to rails-dev, ruby-optimise, and ruby-refactor skills.",
"references": [
"https://guides.rubyonrails.org/testing.html",
"https://rspec.info/",
"https://www.betterspecs.org/",
"https://github.com/thoughtbot/testing-rails",
"https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md",
"https://github.com/sidekiq/sidekiq/wiki/Testing",
"https://evilmartians.com/chronicles/system-of-a-test-setting-up-end-to-end-rails-testing",
"https://www.betterment.com/engineering/guidelines-for-testing-rails-applications",
"https://alchemists.io/articles/rspec_antipatterns",
"https://thoughtbot.com/blog/a-journey-towards-better-testing-practices"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Test Design & Structure (design)
Impact: CRITICAL Description: Mystery guests, obscure assertions, and untraceable setup are the #1 cause of unmaintainable test suites. Four-phase structure and behavior-focused tests make every spec self-documenting and resilient to refactoring.
2. Test Data Management (data)
Impact: CRITICAL Description: Fixture coupling and factory misuse create brittle, slow test suites that break on unrelated changes. Proper factory design with traits and transient attributes keeps data minimal, explicit, and fast to build.
3. Model Testing (model)
Impact: HIGH Description: Models are the foundation of the test pyramid and the cheapest specs to run. Testing validations, associations, scopes, and callbacks at the unit level catches 80% of bugs before they reach integration.
4. Request & Controller Testing (request)
Impact: HIGH Description: Request specs exercise the full HTTP stack—routing, middleware, params, and responses. They replaced controller specs as the Rails-recommended approach and catch integration issues that unit tests miss.
5. System & Acceptance Testing (system)
Impact: MEDIUM-HIGH Description: System tests verify complete user journeys through the browser. Page objects, proper waiting strategies, and focused scenario selection prevent the flaky, slow tests that erode team confidence.
6. Async & Background Job Testing (async)
Impact: MEDIUM Description: Untested jobs silently fail in production. Separating enqueue verification from execution testing with proper Sidekiq/Active Job modes ensures reliable async processing without flaky timing dependencies.
7. Test Performance & Reliability (perf)
Impact: MEDIUM Description: A 30-minute test suite kills developer feedback loops. Parallel execution, database strategy optimization, and flaky test quarantine restore sub-5-minute CI runs.
8. Test Organization & Maintenance (org)
Impact: LOW-MEDIUM Description: Over-abstracted shared examples and deeply nested contexts trade readability for DRY purity. Self-contained specs with focused helpers scale better than clever abstractions.
Separate Enqueue Tests from Perform Tests
Test that the controller or service enqueues the right job with the right arguments (using fake/test mode). Test that the job's perform method produces the correct side effects in a separate spec. Combining both in one test creates a fragile coupling — a change to job internals breaks controller specs, and a change to enqueue arguments breaks job specs.
Incorrect (testing job execution inside a request spec):
# spec/requests/orders_spec.rb
RSpec.describe "POST /orders", type: :request do
it "creates an order and sends the confirmation email" do
user = create(:user, :with_payment_method)
product = create(:product, stock: 5)
# This test does too much: it verifies the request, the job, AND the mailer
perform_enqueued_jobs do
post orders_path, params: {
order: { product_id: product.id, quantity: 2 }
}, headers: auth_headers_for(user)
end
expect(response).to have_http_status(:created)
expect(product.reload.stock).to eq(3)
expect(ActionMailer::Base.deliveries.last.to).to include(user.email)
expect(ActionMailer::Base.deliveries.last.subject).to include("Order Confirmation")
end
endCorrect (request spec tests enqueue, job spec tests execution):
# spec/requests/orders_spec.rb
RSpec.describe "POST /orders", type: :request do
it "creates an order and enqueues a confirmation job" do
user = create(:user, :with_payment_method)
product = create(:product, stock: 5)
expect {
post orders_path, params: {
order: { product_id: product.id, quantity: 2 }
}, headers: auth_headers_for(user)
}.to have_enqueued_job(OrderConfirmationJob).with(Order.last.id)
expect(response).to have_http_status(:created)
end
end
# spec/jobs/order_confirmation_job_spec.rb
RSpec.describe OrderConfirmationJob, type: :job do
describe "#perform" do
it "sends a confirmation email with order details" do
order = create(:order, :with_items)
expect {
described_class.perform_now(order.id)
}.to change { ActionMailer::Base.deliveries.count }.by(1)
mail = ActionMailer::Base.deliveries.last
expect(mail.to).to include(order.user.email)
expect(mail.subject).to include("Order ##{order.number}")
end
it "skips sending if the order has already been confirmed" do
order = create(:order, :confirmed)
expect {
described_class.perform_now(order.id)
}.not_to change { ActionMailer::Base.deliveries.count }
end
end
endReference: Active Job Testing — Rails Guides | Better Specs
Account for Transaction-Aware Job Enqueuing
Rails 5.0+ fires after_commit callbacks inside test transactions, so basic after_commit testing works out of the box. However, Rails 7.2 introduced enqueue_after_transaction_commit for Active Job, which defers job enqueuing until after the transaction commits. In tests using transactional fixtures, the transaction never commits — so jobs configured with this behavior appear to never enqueue. Understand which callback mechanism your code uses and test accordingly.
Incorrect (expecting enqueued jobs inside an uncommitted test transaction with Rails 7.2+):
# app/jobs/fulfillment_sync_job.rb
class FulfillmentSyncJob < ApplicationJob
self.enqueue_after_transaction_commit = true # Rails 7.2+ default for some adapters
def perform(order_id)
Order.find(order_id).sync_to_fulfillment!
end
end
# app/models/order.rb
class Order < ApplicationRecord
after_create_commit :schedule_fulfillment
private
def schedule_fulfillment
FulfillmentSyncJob.perform_later(id)
end
end
# spec/models/order_spec.rb
RSpec.describe Order, type: :model do
it "enqueues a fulfillment sync job" do
# after_create_commit fires (Rails 5.0+), but the job uses
# enqueue_after_transaction_commit — test transaction never commits
expect { create(:order) }.to have_enqueued_job(FulfillmentSyncJob)
# => FAILS: job deferred until transaction commit, which never happens
end
endCorrect (test the callback behavior directly, or disable transaction-aware enqueuing in tests):
# Option 1: Disable transaction-aware enqueuing in test environment
# config/environments/test.rb
Rails.application.configure do
config.active_job.enqueue_after_transaction_commit = :never
end
# Now after_commit callbacks fire AND jobs enqueue immediately
RSpec.describe Order, type: :model do
it "enqueues a fulfillment sync job after creation" do
expect { create(:order) }.to have_enqueued_job(FulfillmentSyncJob)
end
end
# Option 2: Test the callback's side effects directly
RSpec.describe Order, type: :model do
it "calls schedule_fulfillment after commit" do
order = build(:order)
expect(order).to receive(:schedule_fulfillment)
order.save!
order.run_callbacks(:commit)
end
endWhen to use each approach:
- Use Option 1 for most test suites — simplest and most reliable
- Use Option 2 when you need to verify callback wiring specifically
Reference: Rails 7.2 — enqueue_after_transaction_commit | Active Job Basics — Rails Guides
Test Job perform Method Directly
Call .perform_now or .new.perform to test the job's logic as a fast unit test. There is no need to push the job through the queue, serialize arguments, and drain — that adds latency and tests framework plumbing rather than your business logic. Reserve queue integration for the specs that verify enqueue behavior.
Incorrect (enqueuing and draining the queue to test job logic):
RSpec.describe DataExportJob, type: :job do
describe "#perform" do
it "generates a CSV export for the user" do
user = create(:user)
create_list(:order, 3, user: user)
# Unnecessary round-trip through the queue
DataExportJob.perform_later(user.id, "orders", "csv")
perform_enqueued_jobs
export = user.exports.last
expect(export).to be_present
expect(export.format).to eq("csv")
expect(export.row_count).to eq(3)
end
end
endCorrect (calling perform_now directly):
RSpec.describe DataExportJob, type: :job do
describe "#perform" do
it "generates a CSV export with the correct row count" do
user = create(:user)
create_list(:order, 3, user: user)
described_class.perform_now(user.id, "orders", "csv")
export = user.exports.last
expect(export).to have_attributes(
format: "csv",
row_count: 3,
status: "completed"
)
end
it "marks the export as failed when the user has no data" do
user = create(:user)
described_class.perform_now(user.id, "orders", "csv")
export = user.exports.last
expect(export.status).to eq("failed")
expect(export.error_message).to eq("No orders found for export")
end
it "notifies the user when the export is ready" do
user = create(:user)
create(:order, user: user)
expect {
described_class.perform_now(user.id, "orders", "csv")
}.to have_enqueued_job(ActionMailer::MailDeliveryJob)
end
end
endReference: Active Job Basics — Rails Guides | Better Specs
Test Mailer Delivery with deliver_later
When mailers use deliver_later, the email is enqueued as an Active Job rather than delivered synchronously. Use the have_enqueued_mail matcher to verify the mailer was enqueued with the correct arguments. Test the email's content (subject, body, recipients) in a dedicated mailer spec — not inside a request or system spec where the concern is whether the right mailer was triggered.
Incorrect (testing full email content in a request spec):
# spec/requests/orders_spec.rb
RSpec.describe "POST /orders", type: :request do
it "sends a confirmation email with the correct content" do
user = create(:user)
product = create(:product, name: "Ruby Debugger Pro")
perform_enqueued_jobs do
post orders_path,
params: { order: { product_id: product.id, quantity: 1 } },
headers: auth_headers_for(user)
end
# Request spec is now coupled to email template details
mail = ActionMailer::Base.deliveries.last
expect(mail.to).to include(user.email)
expect(mail.subject).to eq("Order Confirmation ##{Order.last.number}")
expect(mail.body.encoded).to include("Ruby Debugger Pro")
expect(mail.body.encoded).to include("$49.99")
end
endCorrect (request spec verifies enqueue, mailer spec verifies content):
# spec/requests/orders_spec.rb
RSpec.describe "POST /orders", type: :request do
it "enqueues a confirmation email for the order" do
user = create(:user)
product = create(:product)
expect {
post orders_path,
params: { order: { product_id: product.id, quantity: 1 } },
headers: auth_headers_for(user)
}.to have_enqueued_mail(OrderMailer, :confirmation).with(a_kind_of(Integer))
end
end
# spec/mailers/order_mailer_spec.rb
RSpec.describe OrderMailer, type: :mailer do
describe "#confirmation" do
it "sends to the customer with order details" do
order = create(:order, :with_items)
mail = described_class.confirmation(order.id)
expect(mail.to).to eq([order.user.email])
expect(mail.subject).to eq("Order Confirmation ##{order.number}")
end
it "includes product names and total in the body" do
order = create(:order, :with_items, total_cents: 4_999)
mail = described_class.confirmation(order.id)
expect(mail.body.encoded).to include(order.items.first.product.name)
expect(mail.body.encoded).to include("$49.99")
end
end
endNote: Use ActionMailer::Base.deliveries for synchronous deliver_now calls. For deliver_later, prefer the have_enqueued_mail matcher which inspects the Active Job queue without executing the job.
Reference: Action Mailer Testing — Rails Guides | RSpec Rails — Mailer Matchers
Use Sidekiq Fake Mode as Default
Configure Sidekiq::Testing.fake! globally so that jobs pushed during tests are stored in a per-worker array without executing. Only switch to inline! when you specifically want to test job execution end-to-end. Running jobs inline by default means every test that touches code which enqueues a job — even indirectly — will execute that job, causing unexpected side effects, slower tests, and hard-to-trace failures.
Incorrect (inline mode globally, jobs execute everywhere):
# spec/rails_helper.rb
require "sidekiq/testing"
Sidekiq::Testing.inline! # Every enqueued job runs immediately in all specs
# spec/requests/users_spec.rb
RSpec.describe "POST /users", type: :request do
it "creates a user" do
# This test only cares about user creation, but inline! also executes:
# - WelcomeEmailJob (sends email, hits mailer)
# - SyncToCrmJob (makes HTTP call to external CRM, fails or times out)
# - AnalyticsTrackingJob (writes to analytics service)
post users_path, params: { user: { email: "new@example.com", name: "Jane" } }
expect(response).to have_http_status(:created)
end
endCorrect (fake mode by default, inline only when testing job execution):
# spec/rails_helper.rb
require "sidekiq/testing"
Sidekiq::Testing.fake! # Jobs are pushed but never executed
# spec/requests/users_spec.rb
RSpec.describe "POST /users", type: :request do
it "creates a user and enqueues the welcome email job" do
post users_path, params: { user: { email: "new@example.com", name: "Jane" } }
expect(response).to have_http_status(:created)
expect(WelcomeEmailJob.jobs.size).to eq(1)
expect(WelcomeEmailJob.jobs.first["args"]).to eq([User.last.id])
end
end
# spec/jobs/welcome_email_job_spec.rb
RSpec.describe WelcomeEmailJob, type: :job do
describe "#perform" do
it "sends a welcome email to the user" do
user = create(:user)
Sidekiq::Testing.inline! do
WelcomeEmailJob.perform_async(user.id)
end
expect(ActionMailer::Base.deliveries.last.to).to include(user.email)
end
end
endNote: If you use Active Job instead of the Sidekiq client API, use ActiveJob::Base.queue_adapter = :test and the have_enqueued_job matcher instead of inspecting SomeJob.jobs.
Reference: Sidekiq Testing — GitHub | Active Job Testing — Rails Guides
Avoid Fixture Coupling Between Tests
Fixtures are shared global state loaded once for the entire suite. When Test A depends on users(:admin) and someone modifies fixtures/users.yml for Test B, Test A breaks silently. Factories create isolated data per test — each test owns its setup and cannot be broken by changes to other tests. Fixtures also make it harder to tell from reading a test what data it depends on.
Incorrect (fixtures create invisible coupling between tests):
# test/fixtures/users.yml
admin:
name: Admin User
email: admin@example.com
role: admin
confirmed_at: <%= Time.current %>
member:
name: Regular Member
email: member@example.com
role: member
confirmed_at: <%= Time.current %>
# spec/models/authorization_spec.rb
RSpec.describe Authorization do
fixtures :users, :projects
describe "#can_delete?" do
it "allows admins to delete projects" do
# Reader must open fixtures/users.yml to understand what "admin" looks like
# Changing the fixture for another test breaks this one
auth = described_class.new(users(:admin))
expect(auth.can_delete?(projects(:active_project))).to be true
end
end
endCorrect (factories isolate each test's data):
RSpec.describe Authorization do
describe "#can_delete?" do
it "allows admins to delete projects" do
admin = create(:user, :admin)
project = create(:project)
auth = described_class.new(admin)
expect(auth.can_delete?(project)).to be true
end
it "denies members from deleting projects they do not own" do
member = create(:user, role: :member)
project = create(:project) # owned by someone else
auth = described_class.new(member)
expect(auth.can_delete?(project)).to be false
end
it "allows members to delete their own projects" do
member = create(:user, role: :member)
project = create(:project, owner: member)
auth = described_class.new(member)
expect(auth.can_delete?(project)).to be true
end
end
endWhen fixtures are acceptable: Rails core team officially recommends fixtures, and they work well for certain patterns:
- Reference/seed data that never changes (countries, currencies, permission definitions)
- Large suites where factory overhead is measured and significant — fixtures load once per suite, not per test
- Teams that maintain disciplined fixture files with clear naming conventions
- Minitest-based Rails applications following the default Rails testing approach
Reference: Thoughtbot — Why Factories?
Prefer build over create
build instantiates an in-memory object without hitting the database. build_stubbed goes further by faking persistence (assigns an id, stubs persisted?). Only use create when the test genuinely requires a persisted record — for example, when testing queries, scopes, or uniqueness constraints that need database state.
Incorrect (create when the test never queries the database):
RSpec.describe ShippingCalculator do
describe "#estimate" do
it "returns free shipping for orders over 50 GBP" do
order = create(:order, subtotal_cents: 6000, shipping_country: "GB")
calculator = described_class.new(order)
estimate = calculator.estimate
expect(estimate.cost_cents).to eq(0)
expect(estimate.label).to eq("Free shipping")
end
it "returns standard rate for orders under 50 GBP" do
order = create(:order, subtotal_cents: 3000, shipping_country: "GB")
calculator = described_class.new(order)
estimate = calculator.estimate
expect(estimate.cost_cents).to eq(499)
end
end
end
# Two INSERTs + associated records for a pure calculation testCorrect (build for in-memory objects, create only when needed):
RSpec.describe ShippingCalculator do
describe "#estimate" do
it "returns free shipping for orders over 50 GBP" do
order = build(:order, subtotal_cents: 6000, shipping_country: "GB")
calculator = described_class.new(order)
estimate = calculator.estimate
expect(estimate.cost_cents).to eq(0)
expect(estimate.label).to eq("Free shipping")
end
it "returns standard rate for orders under 50 GBP" do
order = build(:order, subtotal_cents: 3000, shipping_country: "GB")
calculator = described_class.new(order)
estimate = calculator.estimate
expect(estimate.cost_cents).to eq(499)
end
end
end
# Use build_stubbed when you need an id without persistence:
RSpec.describe OrderPresenter do
it "formats the order reference with the id" do
order = build_stubbed(:order, id: 42)
expect(described_class.new(order).reference).to eq("ORD-000042")
end
end
# Use create only when querying the database:
RSpec.describe Order, ".recent" do
it "returns orders placed within the last 7 days" do
recent_order = create(:order, placed_at: 3.days.ago)
_old_order = create(:order, placed_at: 10.days.ago)
expect(Order.recent).to eq([recent_order])
end
endReference: FactoryBot — Build Strategies
Use Factory Traits for Variations
Traits compose into any combination without creating separate factory definitions for each permutation. Without traits, N variations require N factories; with traits, you compose from a small set. Traits also communicate intent — create(:user, :admin, :confirmed) reads like a specification of what kind of user the test needs.
Incorrect (separate factories for each variation):
# factories/users.rb
FactoryBot.define do
factory :user do
name { "Jane Doe" }
email { generate(:email) }
end
factory :admin_user, class: "User" do
name { "Admin User" }
email { generate(:email) }
role { :admin }
end
factory :confirmed_user, class: "User" do
name { "Confirmed User" }
email { generate(:email) }
confirmed_at { Time.current }
end
factory :admin_confirmed_user, class: "User" do
name { "Admin Confirmed" }
email { generate(:email) }
role { :admin }
confirmed_at { Time.current }
end
# Combinatorial explosion: every new dimension doubles factory count
endCorrect (composable traits on a single factory):
# factories/users.rb
FactoryBot.define do
factory :user do
name { "Jane Doe" }
email { generate(:email) }
role { :member }
confirmed_at { nil }
trait :admin do
role { :admin }
end
trait :confirmed do
confirmed_at { Time.current }
end
trait :with_avatar do
after(:build) do |user|
user.avatar.attach(
io: File.open(Rails.root.join("spec/fixtures/files/avatar.png")),
filename: "avatar.png",
content_type: "image/png"
)
end
end
trait :deactivated do
deactivated_at { 1.week.ago }
end
end
end
# Usage — any combination, always readable:
create(:user, :admin, :confirmed)
create(:user, :confirmed, :with_avatar)
create(:user, :admin, :deactivated)Reference: FactoryBot — Traits
Build Objects with Minimal Attributes
Only specify attributes that are relevant to the behavior under test. Let factory defaults handle everything else. Over-specified factories obscure which attributes actually affect the test outcome and create coupling to unrelated fields — if you change the name column, tests about email validation shouldn't break.
Incorrect (over-specified attributes, unclear what matters):
RSpec.describe User do
describe "#eligible_for_trial?" do
it "returns false when the user has an active subscription" do
user = create(:user,
name: "John Smith",
email: "john@example.com",
phone: "+44 20 7946 0958",
date_of_birth: Date.new(1990, 5, 15),
address_line_1: "123 Main St",
city: "London",
postcode: "SW1A 1AA",
role: "member",
subscription_status: "active", # <-- only this matters
subscription_started_at: 6.months.ago,
referral_source: "google",
marketing_opt_in: true
)
expect(user.eligible_for_trial?).to be false
end
end
endCorrect (only the relevant attribute, factory handles the rest):
RSpec.describe User do
describe "#eligible_for_trial?" do
it "returns false when the user has an active subscription" do
user = build(:user, subscription_status: "active")
expect(user.eligible_for_trial?).to be false
end
it "returns true when the user has never subscribed" do
user = build(:user, subscription_status: nil)
expect(user.eligible_for_trial?).to be true
end
end
endGuideline: If an attribute is specified in a test, it should be because changing that attribute would change the test outcome. If removing it doesn't break the test, remove it.
Reference: Thoughtbot — Writing Better Tests with FactoryBot
Use Sequences for Unique Attributes
Hardcoded values for unique database columns cause ActiveRecord::RecordNotUnique errors the moment a test creates more than one record of that type. Sequences generate unique values deterministically, and they work correctly with parallel test runners like parallel_tests where multiple processes create records simultaneously.
Incorrect (hardcoded values violate uniqueness constraints):
# factories/users.rb
FactoryBot.define do
factory :user do
name { "Test User" }
email { "test@example.com" } # Fails on second create(:user)
username { "testuser" } # Same problem
end
end
# spec/models/team_spec.rb
RSpec.describe Team do
describe "#member_emails" do
it "returns all member email addresses" do
team = create(:team)
create(:user, team: team) # OK
create(:user, team: team) # BOOM: ActiveRecord::RecordNotUnique
expect(team.member_emails.size).to eq(2)
end
end
endCorrect (sequences guarantee uniqueness):
# factories/users.rb
FactoryBot.define do
sequence(:email) { |n| "user#{n}@example.com" }
sequence(:username) { |n| "user_#{n}" }
factory :user do
name { "Test User" }
email
username
trait :with_custom_domain do
transient do
domain { "company.com" }
end
email { generate(:email).gsub("example.com", domain) }
end
end
end
# spec/models/team_spec.rb
RSpec.describe Team do
describe "#member_emails" do
it "returns all member email addresses" do
team = create(:team)
create_list(:user, 3, team: team)
expect(team.member_emails.size).to eq(3)
end
end
endAlternative (inline sequence for factory-scoped uniqueness):
FactoryBot.define do
factory :api_key do
sequence(:token) { |n| "tok_#{SecureRandom.hex(8)}_#{n}" }
sequence(:name) { |n| "API Key #{n}" }
end
endReference: FactoryBot — Sequences
Use Transient Attributes for Complex Setup
Transient attributes are factory parameters that don't map to model columns. They control after(:build) or after(:create) callbacks to set up associated records, conditional logic, or multi-step state. Without them, tests end up with repetitive manual setup that obscures the scenario being tested.
Incorrect (manual multi-step setup repeated across tests):
RSpec.describe Dashboard::SummaryQuery do
describe "#call" do
it "returns the correct published article count for the author" do
author = create(:user)
create(:article, author: author, status: :published, published_at: 1.day.ago)
create(:article, author: author, status: :published, published_at: 2.days.ago)
create(:article, author: author, status: :published, published_at: 3.days.ago)
create(:article, author: author, status: :draft)
create(:article, author: author, status: :draft)
summary = described_class.new(author).call
expect(summary.published_count).to eq(3)
end
end
endCorrect (transient attributes encapsulate complex setup):
# factories/users.rb
FactoryBot.define do
factory :user do
name { "Jane Doe" }
email { generate(:email) }
trait :with_articles do
transient do
published_count { 0 }
draft_count { 0 }
end
after(:create) do |user, evaluator|
create_list(:article, evaluator.published_count, :published, author: user)
create_list(:article, evaluator.draft_count, author: user)
end
end
end
end
# spec/queries/dashboard/summary_query_spec.rb
RSpec.describe Dashboard::SummaryQuery do
describe "#call" do
it "returns the correct published article count for the author" do
author = create(:user, :with_articles, published_count: 3, draft_count: 2)
summary = described_class.new(author).call
expect(summary.published_count).to eq(3)
end
it "returns zero when the author has no published articles" do
author = create(:user, :with_articles, published_count: 0, draft_count: 4)
summary = described_class.new(author).call
expect(summary.published_count).to eq(0)
end
end
endNote: Keep transient attributes in traits rather than the base factory — not every test needs the overhead of associated records, and traits make the factory composable.
Reference: FactoryBot — Transient Attributes
Avoid Conditional Logic in Tests
If/else statements, ternaries, loops, and rescue blocks in tests mean the test itself has multiple execution paths — which means you'd need tests for your tests. Each branch should be a separate example with a deterministic setup and a single expected outcome.
Incorrect (conditional logic makes the test non-deterministic):
RSpec.describe CurrencyConverter do
describe "#convert" do
it "converts between currencies" do
converter = described_class.new
%w[USD EUR GBP JPY].each do |currency|
result = converter.convert(100, from: "USD", to: currency)
if currency == "USD"
expect(result).to eq(100)
elsif currency == "JPY"
expect(result).to be > 100
else
expect(result).to be < 100
end
rescue StandardError => e
fail "Conversion to #{currency} raised: #{e.message}"
end
end
end
endCorrect (one deterministic test per scenario):
RSpec.describe CurrencyConverter do
describe "#convert" do
it "returns the same amount when source and target currency are identical" do
converter = described_class.new
result = converter.convert(100, from: "USD", to: "USD")
expect(result).to eq(100)
end
it "converts USD to EUR using the current exchange rate" do
converter = described_class.new(rates: { "USD_EUR" => 0.92 })
result = converter.convert(100, from: "USD", to: "EUR")
expect(result).to eq(92)
end
it "converts USD to JPY using the current exchange rate" do
converter = described_class.new(rates: { "USD_JPY" => 149.50 })
result = converter.convert(100, from: "USD", to: "JPY")
expect(result).to eq(14_950)
end
it "raises UnsupportedCurrencyError for unknown currency pairs" do
converter = described_class.new
expect {
converter.convert(100, from: "USD", to: "XYZ")
}.to raise_error(CurrencyConverter::UnsupportedCurrencyError)
end
end
endReference: Better Specs
Avoid Mystery Guest Anti-Pattern
All data needed to understand a test must be visible in the test itself. When a test references user, order, or product defined in a let block 80 lines up or in a shared context file, readers must scroll or jump to multiple locations to understand what the test actually does. Inline the data that matters and only extract truly shared, well-named helpers.
Incorrect (mystery guest — test depends on invisible setup):
RSpec.describe RefundPolicy do
let(:store) { create(:store, :premium) }
let(:customer) { create(:customer, store: store, tier: :gold) }
let(:product) { create(:product, store: store, price_cents: 5000, returnable: true) }
let(:order) { create(:order, customer: customer, product: product, placed_at: 20.days.ago) }
describe "#eligible?" do
context "when within return window" do
# Reader must scroll up to understand: what tier? what product? when was it placed?
it "returns true" do
policy = described_class.new(order)
expect(policy).to be_eligible
end
end
end
endCorrect (self-contained — relevant data is inline):
RSpec.describe RefundPolicy do
describe "#eligible?" do
context "when the order is within the 30-day return window" do
it "returns true for a returnable product" do
customer = create(:customer, tier: :gold)
order = create(:order,
customer: customer,
product: create(:product, returnable: true),
placed_at: 20.days.ago
)
policy = described_class.new(order)
expect(policy).to be_eligible
end
end
context "when the order is past the 30-day return window" do
it "returns false regardless of customer tier" do
order = create(:order,
product: create(:product, returnable: true),
placed_at: 31.days.ago
)
policy = described_class.new(order)
expect(policy).not_to be_eligible
end
end
end
endGuideline: If you must use let, keep it within 5-10 lines of the test that uses it, and name it to communicate its role in the test — not just its type.
Reference: Mystery Guest — xUnit Patterns
Test Behavior, Not Implementation
Assert on observable outcomes — return values, state changes, side effects — not on how the code internally achieves them. Tests coupled to implementation details (internal method calls, private state, execution order) break every time you refactor, even when the behavior is unchanged. This creates a test suite that punishes improvement instead of protecting it.
Incorrect (testing internal method calls and execution order):
RSpec.describe SubscriptionService do
describe "#activate" do
it "activates the subscription" do
user = create(:user)
service = described_class.new(user)
expect(service).to receive(:check_eligibility).and_return(true)
expect(service).to receive(:provision_entitlements).with(user)
expect(service).to receive(:schedule_renewal).with(kind_of(Date))
expect(StripeGateway).to receive(:create_subscription)
.with(customer_id: user.stripe_id, price_id: "price_pro")
.and_return(double(id: "sub_123"))
service.activate(plan: :pro)
end
end
endCorrect (testing observable outcomes):
RSpec.describe SubscriptionService do
describe "#activate" do
it "transitions the user to an active subscription on the requested plan" do
user = create(:user, :eligible)
result = described_class.new(user).activate(plan: :pro)
expect(result).to be_success
expect(user.reload.subscription).to have_attributes(
plan: "pro",
status: "active",
expires_at: be_within(1.second).of(1.year.from_now)
)
end
it "provisions the correct feature entitlements for the plan" do
user = create(:user, :eligible)
described_class.new(user).activate(plan: :pro)
expect(user.reload.entitlements.map(&:feature)).to include(
"unlimited_searches",
"priority_support",
"api_access"
)
end
it "returns a failure when the user is not eligible" do
user = create(:user, :ineligible)
result = described_class.new(user).activate(plan: :pro)
expect(result).to be_failure
expect(result.error).to eq("User does not meet eligibility requirements")
end
end
endReference: Better Specs — Testing Behavior
Write Descriptive Test Names
The describe/context/it hierarchy should read like a specification when concatenated. Use describe "#method_name" for instance methods, describe ".method_name" for class methods, context "when/with/without..." for conditions, and it "returns/creates/raises..." for expected outcomes. When a test fails in CI, the full description is the first thing anyone reads.
Incorrect (vague names that convey no specification):
RSpec.describe Invoice do
describe "generate" do
it "works" do
invoice = create(:invoice, :with_line_items)
expect(invoice.generate_pdf).to be_present
end
it "test error" do
invoice = build(:invoice, line_items: [])
expect { invoice.generate_pdf }.to raise_error(StandardError)
end
it "handles tax" do
invoice = create(:invoice, :with_line_items, tax_rate: 0.2)
expect(invoice.total_with_tax).to be > invoice.subtotal
end
end
end
# Failure output: "Invoice generate works FAILED" — tells you nothingCorrect (reads as a specification):
RSpec.describe Invoice do
describe "#generate_pdf" do
context "when the invoice has line items" do
it "returns a PDF binary string" do
invoice = create(:invoice, :with_line_items)
pdf = invoice.generate_pdf
expect(pdf).to start_with("%PDF")
end
end
context "when the invoice has no line items" do
it "raises an EmptyInvoiceError" do
invoice = build(:invoice, line_items: [])
expect { invoice.generate_pdf }.to raise_error(Invoice::EmptyInvoiceError)
end
end
end
describe "#total_with_tax" do
context "when a 20% tax rate applies" do
it "returns the subtotal multiplied by 1.2" do
invoice = create(:invoice, :with_line_items, subtotal_cents: 10_000, tax_rate: 0.2)
expect(invoice.total_with_tax_cents).to eq(12_000)
end
end
end
end
# Failure output: "Invoice #generate_pdf when the invoice has no line items raises an EmptyInvoiceError FAILED"Reference: Better Specs — How to Describe Your Methods
Prefer Explicit over Implicit Subject
RSpec's implicit subject and is_expected hide what's actually being tested. When you read is_expected.to be_valid, you must mentally resolve subject to described_class.new(...) then trace the arguments. An explicit, named variable communicates the domain concept at a glance and makes the test self-documenting.
Incorrect (implicit subject obscures intent):
RSpec.describe Reservation do
subject {
described_class.new(
guest: build(:user),
listing: build(:listing, :available),
check_in: Date.tomorrow,
check_out: Date.tomorrow + 3.days
)
}
it { is_expected.to be_valid }
context "when check-out is before check-in" do
subject {
described_class.new(
guest: build(:user),
listing: build(:listing, :available),
check_in: Date.tomorrow + 3.days,
check_out: Date.tomorrow
)
}
it { is_expected.not_to be_valid }
end
endCorrect (named variable communicates domain intent):
RSpec.describe Reservation do
describe "validations" do
it "is valid with a check-in date before check-out" do
reservation = Reservation.new(
guest: build(:user),
listing: build(:listing, :available),
check_in: Date.tomorrow,
check_out: Date.tomorrow + 3.days
)
expect(reservation).to be_valid
end
it "is invalid when check-out is before check-in" do
reservation = Reservation.new(
guest: build(:user),
listing: build(:listing, :available),
check_in: Date.tomorrow + 3.days,
check_out: Date.tomorrow
)
expect(reservation).not_to be_valid
expect(reservation.errors[:check_out]).to include("must be after check-in date")
end
end
endException: is_expected is acceptable for one-liner shoulda-matchers where the subject is described_class.new with no arguments: it { is_expected.to validate_presence_of(:email) }. Even then, prefer explicit subjects in complex specs.
Reference: RSpec Best Practices — Named Subjects
Use Four-Phase Test Structure
Every test should follow Setup, Exercise, Verify, Teardown (teardown is implicit in RSpec via after hooks and database transactions). Separating phases with blank lines makes the test's intent immediately scannable — readers can identify what's being arranged, what action triggers the behavior, and what outcome is expected without parsing interleaved logic.
Incorrect (phases interleaved, scattered setup across nested contexts):
RSpec.describe OrderService do
let(:warehouse) { create(:warehouse) }
let(:product) { create(:product, warehouse: warehouse, stock: 10) }
let(:customer) { create(:customer, :with_payment_method) }
describe "#place_order" do
let(:discount) { create(:discount, percentage: 15) }
context "when product is in stock" do
let(:params) { { product_id: product.id, quantity: 2, discount_code: discount.code } }
it "works" do
result = described_class.new(customer).place_order(params)
expect(result).to be_success
expect(result.order.total).to eq(17.0)
expect(product.reload.stock).to eq(8)
expect(ActionMailer::Base.deliveries.count).to eq(1)
end
end
end
endCorrect (four phases clearly separated, inline setup):
RSpec.describe OrderService do
describe "#place_order" do
it "creates an order with discounted total and decrements stock" do
# Setup
warehouse = create(:warehouse)
product = create(:product, warehouse: warehouse, stock: 10, price: 10_00)
customer = create(:customer, :with_payment_method)
discount = create(:discount, percentage: 15)
# Exercise
result = described_class.new(customer).place_order(
product_id: product.id,
quantity: 2,
discount_code: discount.code
)
# Verify
expect(result).to be_success
expect(result.order.total_cents).to eq(17_00)
end
it "decrements product stock by the ordered quantity" do
# Setup
product = create(:product, stock: 10)
customer = create(:customer, :with_payment_method)
# Exercise
described_class.new(customer).place_order(product_id: product.id, quantity: 2)
# Verify
expect(product.reload.stock).to eq(8)
end
end
endReference: Four-Phase Test — xUnit Patterns
One Expectation per Test
Each it block should verify one logical behavior. When a test contains multiple unrelated assertions, the first failure masks all subsequent checks — you fix one problem, re-run, and discover the next. Separate it blocks produce failure output that reads like a specification checklist of exactly what broke.
Incorrect (multiple unrelated assertions in one test):
RSpec.describe RegistrationService do
describe "#register" do
it "registers a new user" do
params = { email: "new@example.com", name: "Jane", plan: "starter" }
result = described_class.new.register(params)
expect(result).to be_success
expect(User.find_by(email: "new@example.com")).to be_present
expect(User.last.plan).to eq("starter")
expect(ActionMailer::Base.deliveries.last.to).to include("new@example.com")
end
end
endCorrect (one logical behavior per test, explicit inline setup):
RSpec.describe RegistrationService do
describe "#register" do
it "returns a success result" do
params = { email: "new@example.com", name: "Jane", plan: "starter" }
result = described_class.new.register(params)
expect(result).to be_success
end
it "persists the user with the correct plan" do
params = { email: "new@example.com", name: "Jane", plan: "starter" }
described_class.new.register(params)
expect(User.find_by(email: "new@example.com")).to have_attributes(plan: "starter")
end
it "sends a welcome email to the registered address" do
params = { email: "new@example.com", name: "Jane", plan: "starter" }
described_class.new.register(params)
expect(ActionMailer::Base.deliveries.last.to).to include("new@example.com")
end
end
endAlternative (aggregate_failures for related assertions on the same object):
it "persists the user with complete registration data", :aggregate_failures do
params = { email: "new@example.com", name: "Jane", plan: "starter" }
described_class.new.register(params)
user = User.find_by(email: "new@example.com")
expect(user.name).to eq("Jane")
expect(user.plan).to eq("starter")
expect(user.confirmed_at).to be_nil
endNote: Multiple assertions about the same object are fine when they describe a single logical behavior. Use :aggregate_failures to report all failures without masking. The rule targets assertions about unrelated behaviors in the same test.
Reference: Better Specs — Single Expectation
Avoid Testing ActiveRecord or Framework Behavior
ActiveRecord's save, find, destroy, and query interface are tested by Rails itself — retesting them in your suite wastes time and creates noise that obscures your actual domain logic tests. Focus on the behavior YOU wrote on top of the framework: custom finders, computed attributes, state transitions, and business rules. If a test would pass with an empty model class plus the framework default, it is not testing your code.
Incorrect (testing that ActiveRecord CRUD operations work):
RSpec.describe Project, type: :model do
describe "persistence" do
it "persists to the database" do
project = create(:project, name: "Alpha")
expect(Project.find(project.id)).to eq(project)
end
it "updates attributes" do
project = create(:project, name: "Alpha")
project.update!(name: "Beta")
expect(project.reload.name).to eq("Beta")
end
it "destroys the record" do
project = create(:project)
expect { project.destroy! }.to change(Project, :count).by(-1)
end
it "supports where queries" do
create(:project, status: "active")
create(:project, status: "archived")
expect(Project.where(status: "active").count).to eq(1)
end
end
endCorrect (testing your custom domain logic built on the framework):
RSpec.describe Project, type: :model do
describe "#overdue?" do
it "returns true when the deadline has passed and the project is incomplete" do
project = build(:project, deadline: 1.day.ago, completed_at: nil)
expect(project).to be_overdue
end
it "returns false when the project is completed even if past deadline" do
project = build(:project, deadline: 1.day.ago, completed_at: 2.days.ago)
expect(project).not_to be_overdue
end
end
describe "#budget_utilization" do
it "calculates the percentage of budget spent across all tasks" do
project = create(:project, budget_cents: 100_000)
create(:task, project: project, cost_cents: 25_000)
create(:task, project: project, cost_cents: 50_000)
expect(project.budget_utilization).to eq(0.75)
end
it "returns zero when no tasks have costs" do
project = create(:project, budget_cents: 100_000)
expect(project.budget_utilization).to eq(0.0)
end
end
describe ".stale" do
it "returns projects with no activity in the last 90 days" do
stale = create(:project, last_activity_at: 91.days.ago)
active = create(:project, last_activity_at: 1.day.ago)
expect(Project.stale).to contain_exactly(stale)
end
end
endReference: Better Specs — Don't Test the Framework
Test Associations Explicitly
Untested associations silently break when foreign key columns are missing from migrations or when dependent options are forgotten. A missing dependent: :destroy on a has_many leaves orphaned records that corrupt reporting and violate referential integrity. Use shoulda-matchers for declaration verification and add behavioral tests for the side effects that matter most.
Incorrect (skipping association tests because "ActiveRecord handles it"):
RSpec.describe Team, type: :model do
# "No need to test associations, ActiveRecord takes care of it"
describe "#archive" do
it "archives the team" do
team = create(:team)
team.archive!
expect(team.reload).to be_archived
end
# Never verifies that destroying a team cascades to memberships
end
endCorrect (declaration tests plus behavioral verification of dependent options):
RSpec.describe Team, type: :model do
describe "associations" do
it { is_expected.to belong_to(:organization) }
it { is_expected.to have_many(:memberships).dependent(:destroy) }
it { is_expected.to have_many(:members).through(:memberships).source(:user) }
it { is_expected.to have_one(:subscription).dependent(:destroy) }
end
describe "dependent destroy behavior" do
it "destroys associated memberships when the team is destroyed" do
team = create(:team)
create_list(:membership, 3, team: team)
expect { team.destroy! }.to change(Membership, :count).by(-3)
end
it "does not destroy the parent organization when the team is destroyed" do
team = create(:team)
expect { team.destroy! }.not_to change(Organization, :count)
end
end
describe "foreign key constraints" do
it "cannot create a team without an organization" do
team = build(:team, organization: nil)
expect(team).not_to be_valid
expect(team.errors[:organization]).to include("must exist")
end
end
endReference: shoulda-matchers Association Matchers — thoughtbot
Test Callback Side Effects, Not Callback Existence
Asserting that a callback is registered on a model couples your test to an implementation detail that can change freely during refactoring. The callback might be renamed, moved to a concern, or replaced by a service object — all without affecting observable behavior. Instead, test the outcome: what state or side effect does the callback produce? This keeps tests resilient to structural changes while still catching regressions.
Incorrect (testing callback registration):
RSpec.describe User, type: :model do
describe "callbacks" do
it "has a before_save callback to set defaults" do
callbacks = described_class._save_callbacks.select { |cb| cb.kind == :before }
expect(callbacks.map(&:filter)).to include(:set_defaults)
end
it "has an after_create callback to send welcome email" do
callbacks = described_class._create_callbacks.select { |cb| cb.kind == :after }
expect(callbacks.map(&:filter)).to include(:send_welcome_email)
end
end
endCorrect (testing observable side effects of callbacks):
RSpec.describe User, type: :model do
describe "default role assignment" do
it "assigns the member role when no role is specified" do
user = create(:user, role: nil)
expect(user.role).to eq("member")
end
it "preserves an explicitly set role" do
user = create(:user, role: "admin")
expect(user.role).to eq("admin")
end
end
describe "slug generation" do
it "generates a URL-safe slug from the username on save" do
user = create(:user, username: "Jane Doe")
expect(user.slug).to eq("jane-doe")
end
it "regenerates the slug when the username changes" do
user = create(:user, username: "Jane Doe")
user.update!(username: "Jane Smith")
expect(user.slug).to eq("jane-smith")
end
end
describe "welcome email on creation" do
it "enqueues a welcome email after the user is created" do
expect {
create(:user, email: "new@example.com")
}.to have_enqueued_mail(UserMailer, :welcome).with(a_hash_including(
params: { user: an_instance_of(User) }
))
end
it "does not re-send welcome email on subsequent saves" do
user = create(:user)
expect {
user.update!(name: "Updated Name")
}.not_to have_enqueued_mail(UserMailer, :welcome)
end
end
endReference: Active Record Callbacks — Rails Guides
Test Public Methods with Input/Output Pairs
Model methods contain your domain logic — the business rules that define what your application actually does. A single happy-path test misses the edge cases where bugs hide: nil inputs, empty strings, boundary values, and unicode characters. Use context blocks to organize scenarios by input condition, making it immediately clear which cases are covered and which are missing.
Incorrect (single happy-path test with no edge case coverage):
RSpec.describe User, type: :model do
describe "#full_name" do
it "returns the full name" do
user = build(:user, first_name: "Jane", last_name: "Doe")
expect(user.full_name).to eq("Jane Doe")
end
end
describe "#trial_days_remaining" do
it "returns the remaining days" do
user = build(:user, trial_ends_at: 5.days.from_now)
expect(user.trial_days_remaining).to eq(5)
end
end
endCorrect (comprehensive input/output coverage with context blocks):
RSpec.describe User, type: :model do
describe "#full_name" do
context "when both names are present" do
it "joins first and last name with a space" do
user = build(:user, first_name: "Jane", last_name: "Doe")
expect(user.full_name).to eq("Jane Doe")
end
end
context "when first_name is nil" do
it "returns only the last name without leading space" do
user = build(:user, first_name: nil, last_name: "Doe")
expect(user.full_name).to eq("Doe")
end
end
context "when last_name is nil" do
it "returns only the first name without trailing space" do
user = build(:user, first_name: "Jane", last_name: nil)
expect(user.full_name).to eq("Jane")
end
end
context "when names contain unicode characters" do
it "handles diacritics and CJK characters" do
user = build(:user, first_name: "Jose", last_name: "Garcia")
expect(user.full_name).to eq("Jose Garcia")
end
end
context "when names have leading or trailing whitespace" do
it "strips extra whitespace" do
user = build(:user, first_name: " Jane ", last_name: " Doe ")
expect(user.full_name).to eq("Jane Doe")
end
end
end
describe "#trial_days_remaining" do
context "when trial is active" do
it "returns the number of days until trial expires" do
user = build(:user, trial_ends_at: 5.days.from_now)
expect(user.trial_days_remaining).to eq(5)
end
end
context "when trial expires today" do
it "returns zero" do
user = build(:user, trial_ends_at: Time.current.end_of_day)
expect(user.trial_days_remaining).to eq(0)
end
end
context "when trial has expired" do
it "returns a negative number" do
user = build(:user, trial_ends_at: 3.days.ago)
expect(user.trial_days_remaining).to eq(-3)
end
end
context "when trial_ends_at is nil" do
it "returns nil for users without a trial" do
user = build(:user, trial_ends_at: nil)
expect(user.trial_days_remaining).to be_nil
end
end
end
endReference: Better Specs — Describe Your Methods
Test Enum Transitions and Scopes
Rails enums auto-generate scopes, bang methods, and predicate methods, but they do not enforce valid transitions between states. Skipping enum tests means you discover missing transition guards, incorrect integer mappings, and scope collisions in production. Test that enum values map correctly, generated scopes return the right records, and your domain enforces valid state transitions.
Note: While `model-avoid-testing-framework` says don't test ActiveRecord behavior, enum integer mappings are a deliberate exception — a reordered or removed value silently corrupts existing database rows. The mapping assertion protects your data layer.
Incorrect (skipping enum tests because "Rails generates them automatically"):
RSpec.describe Order, type: :model do
# "Enums are generated by Rails, no need to test"
describe "#fulfill" do
it "fulfills the order" do
order = create(:order)
order.fulfill!
expect(order).to be_fulfilled
end
end
endCorrect (testing enum values, scopes, and transition boundaries):
RSpec.describe Order, type: :model do
describe "status enum" do
it "defines the expected statuses" do
expect(described_class.statuses).to eq(
"pending" => 0,
"confirmed" => 1,
"shipped" => 2,
"delivered" => 3,
"cancelled" => 4
)
end
end
describe "enum scopes" do
it "filters orders by status" do
pending_order = create(:order, status: :pending)
shipped_order = create(:order, status: :shipped)
cancelled_order = create(:order, status: :cancelled)
expect(Order.pending).to contain_exactly(pending_order)
expect(Order.shipped).to contain_exactly(shipped_order)
expect(Order.cancelled).to contain_exactly(cancelled_order)
end
end
describe "status transitions" do
context "when order is pending" do
it "can be confirmed" do
order = create(:order, status: :pending)
order.confirm!
expect(order.reload).to be_confirmed
end
it "can be cancelled" do
order = create(:order, status: :pending)
order.cancel!
expect(order.reload).to be_cancelled
end
end
context "when order is shipped" do
it "cannot be cancelled" do
order = create(:order, status: :shipped)
expect { order.cancel! }.to raise_error(
Order::InvalidTransitionError,
"Cannot cancel a shipped order"
)
end
it "can be marked as delivered" do
order = create(:order, status: :shipped)
order.deliver!
expect(order.reload).to be_delivered
end
end
context "with invalid status values" do
it "raises an ArgumentError for unknown statuses" do
expect { Order.new(status: :nonexistent) }.to raise_error(ArgumentError)
end
end
end
endWhen NOT to use this pattern:
- Simple enums with no transition logic (e.g., a category field with no business rules around changes)
Reference: Active Record Enums — Rails API
Test Scopes with Real Records
Scopes are query builders that generate SQL, and SQL behaves differently from Ruby. Testing the .to_sql string output only verifies syntax — it misses type coercion bugs, timezone mismatches, and NULL handling issues that only surface when the query actually executes against the database. Always create records that should match AND records that should not, then assert the scope returns exactly the right set.
Incorrect (testing the SQL string instead of actual query behavior):
RSpec.describe Article, type: :model do
describe ".published" do
it "scopes to published articles" do
expect(Article.published.to_sql).to include("published_at IS NOT NULL")
end
end
describe ".recent" do
it "orders by created_at" do
expect(Article.recent.to_sql).to include("ORDER BY")
end
end
endCorrect (real records with matching and non-matching data):
RSpec.describe Article, type: :model do
describe ".published" do
it "returns only articles with a published_at timestamp" do
published = create(:article, published_at: 1.day.ago)
draft = create(:article, published_at: nil)
scheduled = create(:article, published_at: 1.day.from_now)
expect(Article.published).to contain_exactly(published, scheduled)
end
end
describe ".visible" do
it "returns published articles that are not archived" do
visible = create(:article, published_at: 1.day.ago, archived_at: nil)
archived = create(:article, published_at: 1.day.ago, archived_at: 1.hour.ago)
draft = create(:article, published_at: nil, archived_at: nil)
expect(Article.visible).to contain_exactly(visible)
end
end
describe ".recent" do
it "returns articles from the last 30 days ordered newest first" do
old = create(:article, created_at: 31.days.ago)
newer = create(:article, created_at: 2.days.ago)
newest = create(:article, created_at: 1.hour.ago)
result = Article.recent
expect(result).to eq([newest, newer])
expect(result).not_to include(old)
end
end
describe ".by_author" do
it "returns only articles belonging to the specified author" do
author = create(:user)
their_article = create(:article, author: author)
other_article = create(:article)
expect(Article.by_author(author)).to contain_exactly(their_article)
end
end
endReference: Active Record Query Interface — Rails Guides
Test Validations with Boundary Cases
Testing that a valid factory builds a valid record tells you nothing — it only proves your factory matches your validations, not that your validations catch bad data. Test each validation by exercising specific invalid states: nil values, boundary lengths, duplicate entries, and malformed formats. Use shoulda-matchers for declarative one-liners and manual specs for nuanced edge cases that one-liners cannot express.
Incorrect (tautological test that only proves the factory works):
RSpec.describe User, type: :model do
describe "validations" do
it "is valid with valid attributes" do
user = build(:user)
expect(user).to be_valid
end
it "is invalid without a name" do
user = build(:user, name: nil)
expect(user).not_to be_valid
end
end
endCorrect (boundary cases with shoulda-matchers and manual edge cases):
RSpec.describe User, type: :model do
describe "validations" do
subject { build(:user) }
# Declarative one-liners for standard validations
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
# Edge cases that one-liners cannot express
context "when email has valid format edge cases" do
it "rejects emails without a TLD" do
user = build(:user, email: "user@localhost")
expect(user).not_to be_valid
expect(user.errors[:email]).to include("is invalid")
end
it "accepts plus-addressed emails" do
user = build(:user, email: "user+tag@example.com")
expect(user).to be_valid
end
end
context "when name is at the boundary length" do
it "accepts a name of exactly 100 characters" do
user = build(:user, name: "a" * 100)
expect(user).to be_valid
end
it "rejects a name of 101 characters" do
user = build(:user, name: "a" * 101)
expect(user).not_to be_valid
end
end
context "when email uniqueness is tested with different casing" do
it "rejects a duplicate email with different casing" do
create(:user, email: "Admin@Example.com")
user = build(:user, email: "admin@example.com")
expect(user).not_to be_valid
expect(user.errors[:email]).to include("has already been taken")
end
end
end
endReference: shoulda-matchers — thoughtbot
Limit Context Nesting to 3 Levels
When describe/context blocks nest beyond 3 levels, the reader must hold multiple conditions in their head simultaneously and scroll up to reconstruct the full setup. Each nesting level also encourages more let overrides, which compound into mystery-guest setups where the effective state at any given it block is nearly impossible to trace. Flatten deeply nested specs into separate describe blocks with inline setup, or extract complex conditions into well-named helper methods.
Incorrect (5 levels deep — reader must scroll through 40+ lines to understand context):
RSpec.describe OrderFulfillment do
describe "#fulfill" do
context "when the order has physical items" do
let(:order) { create(:order, :with_physical_items) }
context "when shipping address is domestic" do
before { order.update!(shipping_country: "US") }
context "when order qualifies for free shipping" do
before { order.update!(subtotal_cents: 100_00) }
context "when warehouse has stock" do
before { create(:inventory, product: order.items.first.product, quantity: 10) }
context "when payment is captured" do
before { order.payment.capture! }
it "creates a shipment with free shipping" do
result = described_class.new(order).fulfill
expect(result.shipment.cost_cents).to eq(0)
end
end
end
end
end
end
end
endCorrect (max 3 levels with inline setup — each test is self-contained):
RSpec.describe OrderFulfillment do
describe "#fulfill" do
context "when a domestic order qualifies for free shipping" do
it "creates a shipment with zero cost" do
order = create(:order, :with_physical_items,
shipping_country: "US",
subtotal_cents: 100_00
)
create(:inventory, product: order.items.first.product, quantity: 10)
order.payment.capture!
result = described_class.new(order).fulfill
expect(result.shipment.cost_cents).to eq(0)
end
end
context "when warehouse is out of stock" do
it "returns a backorder result" do
order = create(:order, :with_physical_items, shipping_country: "US")
order.payment.capture!
# No inventory created — out of stock
result = described_class.new(order).fulfill
expect(result).to be_backordered
end
end
context "when shipping internationally" do
it "calculates international shipping rate" do
order = create(:order, :with_physical_items, shipping_country: "DE")
create(:inventory, product: order.items.first.product, quantity: 5)
order.payment.capture!
result = described_class.new(order).fulfill
expect(result.shipment.cost_cents).to be > 0
end
end
end
endReference: Better Specs — Contexts | thoughtbot — Let's Not
Extract Custom Matchers for Domain Assertions
When the same multi-line assertion appears across three or more specs, the duplication is a signal that the concept deserves a name. Custom RSpec matchers encapsulate domain-specific checks behind expressive predicates like be_publishable or have_valid_address, making the test's intent immediately clear. The matcher also centralizes the failure message, so when the assertion fails it explains what went wrong in domain terms rather than showing a raw attribute comparison.
Incorrect (multi-line assertion repeated across specs — intent is buried in details):
# spec/models/article_spec.rb
RSpec.describe Article do
describe "#ready_for_review?" do
it "returns true when article meets all criteria" do
article = create(:article, :with_body, author: create(:author))
expect(article.title).to be_present
expect(article.body.length).to be >= 300
expect(article.author).to be_present
expect(article.categories.count).to be >= 1
expect(article.cover_image).to be_attached
end
end
end
# spec/services/editorial_workflow_spec.rb — same checks duplicated
RSpec.describe EditorialWorkflow do
describe "#submit" do
it "accepts articles that are ready for review" do
article = create(:article, :complete)
# Same five assertions copied from another spec
expect(article.title).to be_present
expect(article.body.length).to be >= 300
expect(article.author).to be_present
expect(article.categories.count).to be >= 1
expect(article.cover_image).to be_attached
end
end
endCorrect (custom matcher encapsulates domain concept with clear failure message):
# spec/support/matchers/be_ready_for_review.rb
RSpec::Matchers.define :be_ready_for_review do
match do |article|
article.title.present? &&
article.body.to_s.length >= 300 &&
article.author.present? &&
article.categories.any? &&
article.cover_image.attached?
end
failure_message do |article|
missing = []
missing << "title is blank" if article.title.blank?
missing << "body is too short (#{article.body.to_s.length}/300 chars)" if article.body.to_s.length < 300
missing << "author is missing" if article.author.blank?
missing << "no categories assigned" if article.categories.empty?
missing << "cover image not attached" unless article.cover_image.attached?
"expected article to be ready for review, but: #{missing.join(', ')}"
end
end
# spec/models/article_spec.rb
RSpec.describe Article do
describe "#ready_for_review?" do
it "returns true when article meets all editorial criteria" do
article = create(:article, :complete)
expect(article).to be_ready_for_review
end
it "fails when the body is too short" do
article = create(:article, :complete, body: "Too short.")
expect(article).not_to be_ready_for_review
end
end
end
# spec/services/editorial_workflow_spec.rb — same matcher, single line
RSpec.describe EditorialWorkflow do
describe "#submit" do
it "accepts articles that are ready for review" do
article = create(:article, :complete)
expect(article).to be_ready_for_review
expect(described_class.new.submit(article)).to be_success
end
end
endReference: RSpec Custom Matchers | thoughtbot — Writing Custom RSpec Matchers
Mirror App Directory Structure in Specs
When spec/ mirrors app/, every developer can find the spec for any file by mentally replacing app/ with spec/ and appending _spec.rb. No searching, no guessing. This convention also lets RSpec automatically infer spec types from file paths (spec/models/ sets type: :model), reducing boilerplate. Break the convention and developers waste time hunting for specs — or worse, write duplicate specs because they couldn't find the existing ones.
Incorrect (flat structure with inconsistent naming — specs are unfindable):
spec/
user_tests.rb # _tests instead of _spec
order_spec.rb # model spec in root
payment_controller_spec.rb # controller spec in root
sign_in_spec.rb # system spec in root
helpers.rb # shared helpers loose in root
user_factory.rb # factory in root
mailer_tests.rb # wrong suffix, in root
some_shared_context.rb # shared context in root# spec/user_tests.rb — wrong naming convention, wrong location
require "rails_helper"
RSpec.describe User do # No type: :model — RSpec can't infer from path
it "validates email" do
# ...
end
endCorrect (spec/ mirrors app/ — RSpec infers types, specs are instantly locatable):
spec/
factories/
users.rb # FactoryBot definitions
orders.rb
payments.rb
models/
user_spec.rb # mirrors app/models/user.rb
order_spec.rb # mirrors app/models/order.rb
requests/
orders_spec.rb # mirrors app/controllers/orders_controller.rb
api/
v1/
payments_spec.rb # mirrors app/controllers/api/v1/payments_controller.rb
system/
sign_in_spec.rb # user-facing journey
checkout_spec.rb
jobs/
payment_capture_job_spec.rb # mirrors app/jobs/payment_capture_job.rb
mailers/
order_confirmation_mailer_spec.rb # mirrors app/mailers/order_confirmation_mailer.rb
services/
order_fulfillment_spec.rb # mirrors app/services/order_fulfillment.rb
support/
pages/ # page objects for system specs
login_page.rb
checkout_page.rb
matchers/ # custom RSpec matchers
be_ready_for_review.rb
shared_contexts/ # shared RSpec contexts
authenticated_user.rb
rails_helper.rb
spec_helper.rb# spec/models/user_spec.rb — RSpec infers type: :model from the path
require "rails_helper"
RSpec.describe User do
describe "validations" do
it { is_expected.to validate_presence_of(:email) }
end
end
# spec/rails_helper.rb — enable automatic type inference
RSpec.configure do |config|
config.infer_spec_type_from_file_location!
endReference: RSpec Directory Structure | Rails Testing Guide
Use Shared Examples Sparingly
Shared examples move test logic into a separate file, forcing readers to jump between locations to understand what a test actually does. This is the wrong trade-off in test code, where clarity matters more than DRY. Reserve shared examples for true behavioral contracts — interface compliance across multiple implementations, standardized API response formats, or authorization patterns that genuinely share identical behavior. For slight variations, prefer inline duplication where each spec reads top-to-bottom without context-switching.
Incorrect (shared examples for slight variations — test reads like a treasure hunt):
# spec/support/shared_examples/publishable.rb
RSpec.shared_examples "a publishable resource" do |factory_name|
describe "#publish!" do
it "sets published_at" do
resource = create(factory_name)
resource.publish!
expect(resource.published_at).to be_present
end
it "sends a notification" do
resource = create(factory_name)
expect { resource.publish! }.to have_enqueued_job(NotificationJob)
end
end
end
# spec/models/article_spec.rb — reader must find the shared example file to understand the test
RSpec.describe Article, type: :model do
it_behaves_like "a publishable resource", :article
end
# spec/models/podcast_spec.rb
RSpec.describe Podcast, type: :model do
it_behaves_like "a publishable resource", :podcast
# But podcasts also require audio processing before publish...
# Now the shared example needs conditionals or parameters, adding complexity
endCorrect (inline tests with minor duplication — each spec reads independently):
# spec/models/article_spec.rb
RSpec.describe Article, type: :model do
describe "#publish!" do
it "sets the published_at timestamp" do
article = create(:article, published_at: nil)
article.publish!
expect(article.published_at).to be_within(1.second).of(Time.current)
end
it "enqueues a notification to subscribers" do
article = create(:article, author: create(:author, :with_subscribers))
expect { article.publish! }.to have_enqueued_job(NotificationJob)
.with(article.id, "article_published")
end
end
end
# spec/models/podcast_spec.rb — similar but with domain-specific differences inline
RSpec.describe Podcast, type: :model do
describe "#publish!" do
it "sets the published_at timestamp after audio processing completes" do
podcast = create(:podcast, :audio_processed, published_at: nil)
podcast.publish!
expect(podcast.published_at).to be_within(1.second).of(Time.current)
end
it "enqueues a notification and an RSS feed update" do
podcast = create(:podcast, :audio_processed)
expect { podcast.publish! }.to have_enqueued_job(NotificationJob)
.and have_enqueued_job(RssFeedUpdateJob)
end
end
end
# Reserve shared examples for true contracts, e.g., API response format:
# RSpec.shared_examples "a paginated JSON response" do
# it { expect(response.parsed_body).to have_key("data") }
# it { expect(response.parsed_body).to have_key("meta") }
# it { expect(response.parsed_body["meta"]).to have_key("total_count") }
# endReference: Better Specs — Shared Examples | RSpec Shared Examples
Never Mutate State in before(:all)
before(:all) (aliased as before(:context)) runs once before all examples in a group, and its side effects persist across every example — including database records, instance variables, and in-memory state. Unlike before(:each), there is no automatic transaction rollback between examples. If one test modifies a record created in before(:all), every subsequent test sees that mutation. This creates ordering-dependent failures that only appear when tests run in a different order or in parallel.
Incorrect (before(:all) creates shared records that leak mutations between tests):
RSpec.describe Subscription do
before(:all) do
@plan = create(:plan, price_cents: 999, trial_days: 14)
@user = create(:user)
@subscription = create(:subscription, user: @user, plan: @plan)
end
it "starts with a trial period" do
expect(@subscription.trial?).to be true
end
it "can be cancelled" do
@subscription.cancel! # Mutation persists — @subscription is now cancelled for all later tests
expect(@subscription).to be_cancelled
end
it "renews after trial ends" do
# FAILS: @subscription was cancelled by the previous test
travel_to 15.days.from_now do
expect(@subscription.renewable?).to be true
end
end
after(:all) do
# Manual cleanup required — easy to forget, doesn't rollback mid-test mutations
Subscription.delete_all
User.delete_all
Plan.delete_all
end
endCorrect (let and before(:each) provide isolated state per example):
RSpec.describe Subscription do
let(:plan) { create(:plan, price_cents: 999, trial_days: 14) }
let(:user) { create(:user) }
let(:subscription) { create(:subscription, user: user, plan: plan) }
it "starts with a trial period" do
expect(subscription.trial?).to be true
end
it "can be cancelled" do
subscription.cancel!
expect(subscription).to be_cancelled
end
it "renews after trial ends" do
# Each test gets a fresh subscription — this works regardless of test order
travel_to 15.days.from_now do
expect(subscription.renewable?).to be true
end
end
end
# If you need expensive one-time setup (e.g., seed reference data), use before(:all)
# ONLY for read-only data that no test will ever modify:
RSpec.describe TaxCalculator do
before(:all) do
@tax_rates = YAML.load_file(Rails.root.join("config/tax_rates.yml")).freeze
end
it "calculates UK VAT" do
calculator = described_class.new(rates: @tax_rates)
expect(calculator.compute(country: "GB", amount: 100_00)).to eq(120_00)
end
endReference: RSpec before and after hooks | Better Specs — Let and Before
Use Transaction Strategy for Non-System Tests
Transactional cleanup wraps each test in a database transaction and rolls it back at the end — this is essentially free because no rows are ever committed. Truncation physically deletes all rows from every table after each test, which is orders of magnitude slower. Since Rails 5.1+, the shared database connection means transactional fixtures work for system tests too. Only use truncation if your setup requires it (multiple databases, custom drivers).
Incorrect (truncation strategy for all specs — 10-50× slower cleanup):
# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.use_transactional_fixtures = false # Disabled globally
config.before(:suite) do
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
endCorrect (transactional fixtures for all spec types in Rails 7+):
# spec/rails_helper.rb — simplest and fastest approach
RSpec.configure do |config|
config.use_transactional_fixtures = true # Instant rollback, zero cost
end
# No database_cleaner gem needed for standard Rails 7+ setups.
# The shared database connection handles system test visibility.When database_cleaner is still needed:
# Only for multi-database setups or non-standard threading
RSpec.configure do |config|
config.use_transactional_fixtures = true # Default for model/request specs
# Override only for specs that need truncation
config.before(:each, :truncation) do
DatabaseCleaner.strategy = :truncation
end
config.around(:each, :truncation) do |example|
DatabaseCleaner.cleaning { example.run }
end
endReference: Rails Testing Guide | Better Specs — Database Cleaning
Run Tests in Parallel
A sequential test suite that grows beyond 10 minutes destroys developer feedback loops and discourages running the full suite locally. Use the parallel_tests gem to split specs across CPU cores. Each worker gets its own numbered database (e.g., myapp_test2, myapp_test3) to avoid transaction conflicts.
Incorrect (entire suite runs sequentially on a single process):
# Gemfile — no parallelization configured
group :test do
gem "rspec-rails"
gem "factory_bot_rails"
end
# .github/workflows/ci.yml
# Single-process run: 35 minutes on a 4-core runner
jobs:
test:
steps:
- run: bundle exec rspec
# spec/rails_helper.rb — no parallel configuration
RSpec.configure do |config|
config.use_transactional_fixtures = true
endCorrect (parallel_tests gem splits across all available cores):
# Gemfile
group :test do
gem "rspec-rails"
gem "factory_bot_rails"
gem "parallel_tests"
end
# .github/workflows/ci.yml — parallel execution: ~8 minutes on same runner
jobs:
test:
steps:
- run: bundle exec rake parallel:setup
- run: bundle exec rake parallel:spec
# config/database.yml — each worker gets its own database
test:
database: myapp_test<%= ENV["TEST_ENV_NUMBER"] %>
# spec/rails_helper.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
endCI optimization — split across matrix workers:
# .github/workflows/ci.yml — distribute across CI nodes for even faster runs
jobs:
test:
strategy:
matrix:
ci_node_total: [4]
ci_node_index: [0, 1, 2, 3]
steps:
- run: bundle exec rake parallel:setup
- run: |
bundle exec parallel_test spec/ \
--type rspec \
-n ${{ matrix.ci_node_total }} \
--only-group ${{ matrix.ci_node_index }}Reference: parallel_tests gem
Profile and Fix Slow Specs
Most teams accept a slow suite without investigating where the time actually goes. Running rspec --profile surfaces the slowest examples, and the fixes are almost always the same: replacing create with build or build_stubbed when persistence isn't needed, converting system tests to request specs when JavaScript isn't required, and adding missing database indexes to test databases. A targeted 30-minute profiling session often cuts suite time by 30-50%.
Incorrect (accepting slow suite without investigation):
# spec/models/invoice_spec.rb — every test hits the database unnecessarily
RSpec.describe Invoice do
describe "#total" do
it "sums line items" do
invoice = create(:invoice) # INSERT into invoices
create(:line_item, invoice: invoice, amount_cents: 1000) # INSERT into line_items
create(:line_item, invoice: invoice, amount_cents: 2500) # INSERT into line_items
expect(invoice.total_cents).to eq(3500)
end
end
describe "#overdue?" do
it "returns true when past due date" do
invoice = create(:invoice, due_date: 1.day.ago, status: :unpaid)
expect(invoice).to be_overdue
end
end
end
# spec/system/admin_reports_spec.rb — full browser test for a data table
RSpec.describe "Admin reports", type: :system do
it "displays filtered invoices" do
create_list(:invoice, 50, status: :paid)
visit admin_reports_path
select "Paid", from: "Status"
click_on "Filter"
expect(page).to have_css("table tbody tr", count: 50)
end
endCorrect (profiled and optimized — build where possible, request spec where sufficient):
# Run: bundle exec rspec --profile 20
# Identify slowest specs, then apply targeted fixes:
# spec/models/invoice_spec.rb — no database needed for pure calculation
RSpec.describe Invoice do
describe "#total" do
it "sums line items" do
invoice = build(:invoice)
invoice.line_items = build_list(:line_item, 2, amount_cents: [1000, 2500].cycle)
expect(invoice.total_cents).to eq(3500)
end
end
describe "#overdue?" do
it "returns true when past due date" do
invoice = build(:invoice, due_date: 1.day.ago, status: :unpaid)
expect(invoice).to be_overdue
end
end
end
# spec/requests/admin_reports_spec.rb — request spec is 10x faster than system test
RSpec.describe "Admin reports", type: :request do
it "returns filtered invoices as JSON" do
create_list(:invoice, 3, status: :paid)
create(:invoice, status: :unpaid)
get admin_reports_path, params: { status: "paid" }
expect(response).to have_http_status(:ok)
expect(response.parsed_body["invoices"].size).to eq(3)
end
end
# Tip: Add test-specific indexes if queries are slow
# db/migrate/xxx_add_index_on_invoices_status.rb (if missing)Reference: RSpec --profile flag | thoughtbot — Speed Up Tests
Quarantine Flaky Tests Instead of Retrying
Auto-retrying failed tests is a tax on every CI run — it doubles the time for flaky specs and trains the team to ignore failures. Instead, tag flaky tests with a quarantine label, exclude them from the main suite, and run them in a separate non-blocking CI job. This keeps the main build fast and green while giving you a visible backlog of tests that need root-cause analysis (usually timing issues, order-dependent state, or external service dependencies).
Incorrect (auto-retry hides flaky tests and wastes CI time):
# spec/support/retry.rb — blanket retry for all failures
RSpec.configure do |config|
config.around(:each) do |example|
attempts = 0
begin
attempts += 1
example.run
raise example.exception if example.exception
rescue StandardError
retry if attempts < 3
end
end
end
# CI output shows "passed" but the test failed twice before passing.
# Nobody investigates, the flaky test stays forever.
# CI time increases by retry overhead on every run.Correct (quarantine tag isolates flaky tests for focused investigation):
# spec/support/quarantine.rb
RSpec.configure do |config|
# Exclude quarantined tests from the default suite
config.filter_run_excluding quarantine: true
# When specifically running quarantined tests, only run those
config.filter_run_including quarantine: true if ENV["RUN_QUARANTINE"]
end
# spec/system/payment_checkout_spec.rb — tagged as quarantined with a reason
RSpec.describe "Payment checkout", type: :system do
it "processes a Stripe payment end-to-end", :quarantine, quarantine_reason: "Stripe webhook timing" do
customer = create(:customer, :with_card)
visit new_checkout_path
CheckoutPage.new.complete_purchase(customer: customer)
expect(page).to have_text("Payment confirmed")
end
end
# .github/workflows/ci.yml
# Main suite (blocking): excludes quarantined tests
# jobs:
# test:
# steps:
# - run: bundle exec rspec
#
# Quarantine suite (non-blocking, runs separately):
# quarantine:
# continue-on-error: true
# steps:
# - run: RUN_QUARANTINE=1 bundle exec rspec
# Track quarantined tests count as a metric — it should trend toward zero.Reference: Quarantine — Test Flakiness at Scale | rspec-retry gem (use sparingly)
Assert JSON Response Structure
API consumers depend on the shape of your JSON responses — a renamed key, a missing nested object, or a changed type breaks client applications silently. String inclusion checks like expect(response.body).to include("user") pass even when the structure is completely wrong (it matches inside any string value). Parse the JSON and assert on the structure with specific key expectations, ensuring your API contract is tested as precisely as your status codes.
Incorrect (string matching on raw response body):
RSpec.describe "Users API", type: :request do
describe "GET /api/users/:id" do
it "returns user data" do
user = create(:user, name: "Jane Doe", email: "jane@example.com")
get api_user_path(user), headers: auth_headers
expect(response.body).to include("Jane Doe")
expect(response.body).to include("jane@example.com")
# Passes even if structure is { "error": "jane@example.com not found" }
end
end
describe "GET /api/users" do
it "returns users" do
create_list(:user, 3)
get api_users_path, headers: auth_headers
expect(response.body).to include("user")
# Matches literally anything containing the substring "user"
end
end
endCorrect (parsed JSON with structural assertions):
RSpec.describe "Users API", type: :request do
describe "GET /api/users/:id" do
it "returns the user with expected attributes" do
user = create(:user, name: "Jane Doe", email: "jane@example.com")
get api_user_path(user), headers: auth_headers
expect(response).to have_http_status(:ok)
json = response.parsed_body
expect(json).to include(
"id" => user.id,
"name" => "Jane Doe",
"email" => "jane@example.com",
"created_at" => user.created_at.as_json
)
end
it "does not expose sensitive attributes" do
user = create(:user)
get api_user_path(user), headers: auth_headers
json = response.parsed_body
expect(json.keys).not_to include("password_digest", "reset_token", "otp_secret")
end
end
describe "GET /api/users" do
it "returns a paginated collection with metadata" do
create_list(:user, 3)
get api_users_path, params: { page: 1, per_page: 2 }, headers: auth_headers
expect(response).to have_http_status(:ok)
json = response.parsed_body
expect(json["data"].length).to eq(2)
expect(json["data"].first).to include("id", "name", "email")
expect(json["meta"]).to include(
"current_page" => 1,
"total_pages" => 2,
"total_count" => 3
)
end
end
describe "POST /api/users" do
it "returns the created resource with a location header" do
params = { user: { name: "New User", email: "new@example.com", password: "securepass123" } }
post api_users_path, params: params, headers: auth_headers
expect(response).to have_http_status(:created)
json = response.parsed_body
expect(json).to include("id", "name", "email")
expect(json).not_to include("password", "password_digest")
expect(response.headers["Location"]).to eq(api_user_url(json["id"]))
end
it "returns structured validation errors" do
params = { user: { name: "", email: "invalid" } }
post api_users_path, params: params, headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
json = response.parsed_body
expect(json["errors"]).to include(
a_hash_including("field" => "name", "message" => "can't be blank"),
a_hash_including("field" => "email", "message" => "is invalid")
)
end
end
endReference: RSpec Rails Request Specs — rspec.info
Use Request Specs over Controller Specs
Controller specs bypass routing, middleware, and Rack processing — they call controller actions directly as methods, which means your tests never exercise the code path that production traffic actually hits. Since Rails 5.1, the Rails team officially recommends request specs, and assigns and assert_template are no longer available without an extra gem. Request specs catch routing typos, middleware misconfiguration, and parameter parsing bugs that controller specs silently miss.
Incorrect (deprecated controller spec style):
RSpec.describe UsersController, type: :controller do
describe "GET #index" do
it "returns a successful response" do
get :index
expect(response).to be_successful
expect(assigns(:users)).to eq(User.all) # assigns is deprecated
end
end
describe "POST #create" do
it "creates a user" do
post :create, params: { user: { name: "Jane", email: "jane@example.com" } }
expect(assigns(:user)).to be_persisted
expect(response).to redirect_to(user_path(assigns(:user)))
end
end
endCorrect (request spec exercising the full HTTP stack):
RSpec.describe "Users", type: :request do
describe "GET /users" do
it "returns a successful response" do
create_list(:user, 3)
get users_path
expect(response).to have_http_status(:ok)
end
end
describe "POST /users" do
it "creates a user and redirects to the user page" do
valid_params = { user: { name: "Jane", email: "jane@example.com" } }
expect {
post users_path, params: valid_params
}.to change(User, :count).by(1)
expect(response).to redirect_to(user_path(User.last))
end
it "returns unprocessable entity with invalid params" do
invalid_params = { user: { name: "", email: "" } }
post users_path, params: invalid_params
expect(response).to have_http_status(:unprocessable_entity)
end
end
endReference: RSpec Rails Request Specs — rspec.info
Test Authentication Boundaries
Every authenticated endpoint must be tested from both sides of the authentication boundary: logged in and not logged in. Testing only the happy path with a signed-in user means a missing before_action :authenticate_user! goes undetected until production, leaving sensitive data exposed. The unauthenticated context should always come first — it documents the security contract before the functional behavior.
Incorrect (only testing the happy path with a logged-in user):
RSpec.describe "Projects", type: :request do
describe "GET /projects" do
it "lists the user's projects" do
user = create(:user)
sign_in(user)
create_list(:project, 3, owner: user)
get projects_path
expect(response).to have_http_status(:ok)
end
end
describe "POST /projects" do
it "creates a new project" do
user = create(:user)
sign_in(user)
post projects_path, params: { project: { name: "My App" } }
expect(response).to redirect_to(project_path(Project.last))
end
end
# No test for what happens when the user is NOT signed in
endCorrect (unauthenticated and authenticated contexts for every endpoint):
RSpec.describe "Projects", type: :request do
describe "GET /projects" do
context "when not authenticated" do
it "redirects to the sign-in page" do
get projects_path
expect(response).to redirect_to(new_session_path)
end
end
context "when authenticated" do
it "returns the user's projects" do
user = create(:user)
sign_in(user)
create_list(:project, 3, owner: user)
get projects_path
expect(response).to have_http_status(:ok)
end
end
end
describe "DELETE /projects/:id" do
context "when not authenticated" do
it "returns 401 Unauthorized for API endpoints" do
project = create(:project)
delete api_project_path(project), headers: { "Accept" => "application/json" }
expect(response).to have_http_status(:unauthorized)
end
end
context "when authenticated" do
it "deletes the project and redirects" do
user = create(:user)
project = create(:project, owner: user)
sign_in(user)
expect {
delete project_path(project)
}.to change(Project, :count).by(-1)
expect(response).to redirect_to(projects_path)
end
end
end
endWhen NOT to use this pattern:
- Public endpoints that intentionally allow unauthenticated access (e.g., landing pages, public API endpoints)
Reference: Devise Test Helpers — Devise wiki
Test Authorization for Each Role
Authorization bugs are among the most dangerous security vulnerabilities because they silently grant access instead of failing loudly. Testing only that an admin can perform an action tells you nothing about whether a regular member, a user from another organization, or a user accessing another user's resources is properly blocked. Test every role boundary explicitly, including cross-tenant access attempts that should return 404 (not 403, to avoid leaking resource existence).
Incorrect (testing only admin access works):
RSpec.describe "Admin: Users", type: :request do
describe "DELETE /admin/users/:id" do
it "allows admins to delete users" do
admin = create(:user, role: :admin)
target_user = create(:user)
sign_in(admin)
expect {
delete admin_user_path(target_user)
}.to change(User, :count).by(-1)
expect(response).to redirect_to(admin_users_path)
end
# What happens when a non-admin hits this endpoint?
end
endCorrect (testing each role boundary and cross-resource access):
RSpec.describe "Admin: Users", type: :request do
describe "DELETE /admin/users/:id" do
context "as an admin" do
it "deletes the user and redirects to the user list" do
admin = create(:user, role: :admin)
target_user = create(:user)
sign_in(admin)
expect {
delete admin_user_path(target_user)
}.to change(User, :count).by(-1)
expect(response).to redirect_to(admin_users_path)
end
end
context "as a regular member" do
it "returns 403 Forbidden" do
member = create(:user, role: :member)
target_user = create(:user)
sign_in(member)
delete admin_user_path(target_user)
expect(response).to have_http_status(:forbidden)
end
end
context "when not authenticated" do
it "redirects to sign-in" do
target_user = create(:user)
delete admin_user_path(target_user)
expect(response).to redirect_to(new_session_path)
end
end
end
end
RSpec.describe "Projects", type: :request do
describe "PATCH /projects/:id" do
context "as the project owner" do
it "updates the project" do
owner = create(:user)
project = create(:project, owner: owner)
sign_in(owner)
patch project_path(project), params: { project: { name: "Renamed" } }
expect(response).to redirect_to(project_path(project))
expect(project.reload.name).to eq("Renamed")
end
end
context "as a team member with read-only access" do
it "returns 403 Forbidden" do
project = create(:project)
reader = create(:user)
create(:membership, project: project, user: reader, role: :viewer)
sign_in(reader)
patch project_path(project), params: { project: { name: "Hijacked" } }
expect(response).to have_http_status(:forbidden)
expect(project.reload.name).not_to eq("Hijacked")
end
end
context "as a user from another organization" do
it "returns 404 Not Found to avoid leaking resource existence" do
project = create(:project)
outsider = create(:user)
sign_in(outsider)
patch project_path(project), params: { project: { name: "Hijacked" } }
expect(response).to have_http_status(:not_found)
end
end
end
endReference: Pundit — Authorization for Ruby on Rails
Test Parameter Validation and Edge Cases
Request specs that only send valid parameters assume your strong params are correct and your error handling works — two assumptions that fail silently when a developer adds a new field but forgets to permit it, or when invalid input produces a 500 instead of a 422. Test the full parameter spectrum: valid params, missing required params, invalid format params, and extra unpermitted params that should be silently ignored.
Incorrect (only testing with valid params):
RSpec.describe "Articles", type: :request do
describe "POST /articles" do
it "creates an article" do
user = create(:user)
sign_in(user)
post articles_path, params: {
article: { title: "Good Title", body: "Good body content", category: "tech" }
}
expect(response).to redirect_to(article_path(Article.last))
end
end
describe "PATCH /articles/:id" do
it "updates the article" do
article = create(:article)
sign_in(article.author)
patch article_path(article), params: { article: { title: "Updated" } }
expect(article.reload.title).to eq("Updated")
end
end
endCorrect (testing valid, missing, invalid, and unpermitted params):
RSpec.describe "Articles", type: :request do
describe "POST /articles" do
let(:user) { create(:user) }
before { sign_in(user) }
context "with valid params" do
it "creates the article and redirects" do
valid_params = { article: { title: "Testing Guide", body: "Detailed content", category: "tech" } }
expect {
post articles_path, params: valid_params
}.to change(Article, :count).by(1)
expect(response).to redirect_to(article_path(Article.last))
end
end
context "with missing required params" do
it "returns 422 when title is blank" do
post articles_path, params: { article: { title: "", body: "Some body" } }
expect(response).to have_http_status(:unprocessable_entity)
end
it "returns 422 when body is missing entirely" do
post articles_path, params: { article: { title: "No Body" } }
expect(response).to have_http_status(:unprocessable_entity)
end
end
context "with invalid param formats" do
it "returns 422 when category is not in allowed values" do
invalid_params = { article: { title: "Valid", body: "Valid", category: "nonexistent" } }
post articles_path, params: invalid_params
expect(response).to have_http_status(:unprocessable_entity)
end
end
context "with unpermitted params" do
it "ignores unpermitted attributes and creates the article" do
params_with_extras = {
article: {
title: "Testing Guide",
body: "Content",
category: "tech",
author_id: create(:user, role: :admin).id, # Attempt to assign a different author
featured: true # Unpermitted field
}
}
post articles_path, params: params_with_extras
article = Article.last
expect(article.author).to eq(user) # author_id was ignored
expect(article).not_to be_featured # featured was ignored
end
end
context "with malformed request body" do
it "returns 400 when the article key is missing" do
post articles_path, params: { title: "Orphaned" }
expect(response).to have_http_status(:bad_request)
end
end
end
endReference: Action Controller Parameters — Rails Guides
Assert HTTP Status Codes Explicitly
Checking only the response body hides entire categories of bugs. An endpoint that returns a 200 with an error message in the body looks successful to monitoring tools and load balancers. An endpoint that silently returns 302 instead of 401 masks a broken authentication check. Always assert the status code first, then the body. Use symbolic names (:ok, :created, :not_found) for readability — they make the test read like a specification.
Incorrect (only checking body content, missing status assertions):
RSpec.describe "Articles API", type: :request do
describe "GET /api/articles/:id" do
it "returns the article" do
article = create(:article, title: "Testing Rails")
get api_article_path(article)
json = JSON.parse(response.body)
expect(json["title"]).to eq("Testing Rails")
# No status assertion — a 500 with the right body passes this test
end
end
describe "DELETE /api/articles/:id" do
it "deletes the article" do
article = create(:article)
delete api_article_path(article)
expect(Article.find_by(id: article.id)).to be_nil
# No status assertion — could be 200, 204, 302, or 500
end
end
endCorrect (explicit status codes with symbolic names):
RSpec.describe "Articles API", type: :request do
describe "GET /api/articles/:id" do
it "returns the article with 200 OK" do
article = create(:article, title: "Testing Rails")
get api_article_path(article), headers: auth_headers_for(create(:user))
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to include("title" => "Testing Rails")
end
it "returns 404 when the article does not exist" do
get api_article_path(id: "nonexistent"), headers: auth_headers_for(create(:user))
expect(response).to have_http_status(:not_found)
end
end
describe "POST /api/articles" do
it "returns 201 Created with the new article" do
user = create(:user)
params = { article: { title: "New Post", body: "Content here" } }
post api_articles_path, params: params, headers: auth_headers_for(user)
expect(response).to have_http_status(:created)
end
it "returns 422 Unprocessable Entity with validation errors" do
user = create(:user)
params = { article: { title: "", body: "" } }
post api_articles_path, params: params, headers: auth_headers_for(user)
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)).to include("errors")
end
end
describe "DELETE /api/articles/:id" do
it "returns 204 No Content after deletion" do
article = create(:article)
delete api_article_path(article), headers: auth_headers_for(article.author)
expect(response).to have_http_status(:no_content)
end
end
endReference: HTTP Status Codes — MDN Web Docs
Never Use sleep in System Tests
Capybara automatically retries assertions and finders up to Capybara.default_max_wait_time seconds. Using sleep is either too short (causing intermittent failures on slower CI runners) or too long (wasting minutes across a test suite). Replace sleep with Capybara's built-in waiting matchers — they poll the DOM and return as soon as the condition is met, giving you both speed and reliability.
Incorrect (hardcoded sleep introduces flakiness and waste):
RSpec.describe "Project creation", type: :system do
it "creates a project and displays a success message" do
user = create(:user)
sign_in user
visit new_project_path
fill_in "Project name", with: "New Dashboard"
click_on "Create project"
sleep 3 # Waiting for Turbo to render the response
expect(page).to have_content("Project created successfully")
sleep 2 # Waiting for the project to appear in the sidebar
expect(page).to have_css("nav", text: "New Dashboard")
end
it "shows a loading indicator while processing" do
visit projects_path
click_on "Generate report"
sleep 1
expect(page).to have_css(".spinner")
sleep 5 # Wait for report to finish
expect(page).not_to have_css(".spinner")
end
endCorrect (Capybara waits automatically, returns as soon as condition is met):
RSpec.describe "Project creation", type: :system do
it "creates a project and displays a success message" do
user = create(:user)
sign_in user
visit new_project_path
fill_in "Project name", with: "New Dashboard"
click_on "Create project"
expect(page).to have_content("Project created successfully")
expect(page).to have_css("nav", text: "New Dashboard")
end
it "shows a loading indicator while processing" do
visit projects_path
click_on "Generate report"
expect(page).to have_css(".spinner")
expect(page).to have_no_css(".spinner", wait: 10) # Override wait for slow operations
end
endNote: If the default wait time is too short for a specific operation (e.g., file uploads, long-running jobs), pass wait: to the individual matcher instead of bumping the global default_max_wait_time.
Reference: Capybara — Asynchronous JavaScript | Evil Martians — System of a Test
Related skills
FAQ
What does rails-testing do?
rails-testing: A skill for development. This provides functionality for development workflows.
When should I use rails-testing?
When you need to use rails-testing for development tasks, or when rails-testing: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
rails-testing.