RSpec Best Practices: Naming, subject/let, Custom Matchers, and Performance
Good RSpec tests are fast, readable, and fail for the right reasons. Bad ones are slow, coupled to implementation, full of mocks, and when they fail you can't tell what's broken. This post covers the conventions and patterns that separate maintainable RSpec suites from the ones nobody wants to touch.
Naming Conventions
Naming is the highest-leverage practice in RSpec. The names of your describe, context, and it blocks form a sentence in the test output, and that sentence is what tells you what broke when a test fails without you having to open the file.
describe
Use the class name or method name:
describe User do
describe "#full_name" do # instance method
# ...
end
describe ".find_by_email" do # class method
# ...
end
endThe # prefix for instance methods and . for class methods is a Ruby documentation convention. It reads correctly in the output:
User
#full_name
...
.find_by_email
...context
Always start context strings with "when", "with", "without", or "given":
context "when the user is an admin" do
context "when the password is invalid" do
context "with no records in the database" do
context "without required permissions" do
context "given an expired token" doNever: context "admin user", context "invalid password". Those don't complete the sentence started by describe.
it
The it string should complete the sentence "it ___":
it "returns the full name"
it "raises ArgumentError when the amount is negative"
it "sends a confirmation email"
it "does not persist the record"Avoid implementation details in it descriptions:
# Bad — describes implementation
it "calls User.find with the given id"
# Good — describes behavior
it "returns the user with the given id"
it "raises RecordNotFound when the id doesn't exist"When you focus on outcomes instead of implementation, you can refactor the internals without rewriting tests.
Putting it together
The full nested output should read as a coherent specification:
OrderProcessor#process
when payment succeeds
marks the order as completed
sends a confirmation email to the user
returns true
when payment fails
marks the order as failed
sends a failure notification to the user
returns false
when the gateway raises an exception
propagates the exception
does not change the order statusIf someone reads this output without opening the source, they understand what OrderProcessor#process is supposed to do.
subject and let
subject
subject represents the primary object under test. When describe receives a class, RSpec sets an implicit subject:
describe User do
it { is_expected.to respond_to(:full_name) }
it { is_expected.to be_valid }
endName your subject explicitly when you need to reference it by name:
describe User do
subject(:user) { User.new(first_name: "Alice", last_name: "Smith") }
it "has a full name" do
expect(user.full_name).to eq("Alice Smith")
end
endUse implicit subject for one-liner expectations. Use named subject when you need to call methods on it.
let
let defines memoized helpers. The block runs once per example and caches the result:
describe OrderProcessor do
let(:gateway) { instance_double(PaymentGateway) }
let(:notifier) { instance_double(NotificationService) }
let(:processor) { OrderProcessor.new(gateway, notifier) }
let(:order) { build_stubbed(:order, amount: 50.00) }
endRules for let:
Use let instead of instance variables in before blocks. @user = User.new in a before block is harder to trace than let(:user) { User.new }. let is lazy — it doesn't run until referenced — which makes specs faster and clearer about what each example actually needs.
# Bad
before { @user = create(:user) }
it "..." do expect(@user.name).to eq(...) end
# Good
let(:user) { create(:user) }
it "..." do expect(user.name).to eq(...) endName let blocks after what they represent, not what they do. let(:user) not let(:create_user).
Use let! sparingly. let! creates the object before every example in the group, even examples that don't use it. This wastes time and can create unexpected state. Use it only when the side effect (like database insertion) is part of the test setup regardless of whether you reference the object directly.
# Correct use of let! — the database row needs to exist
let!(:admin) { create(:user, :admin) }
it "is included in User.admins" do
expect(User.admins).to include(admin)
end
it "is excluded from User.members" do
expect(User.members).not_to include(admin)
endDon't cascade let more than two levels. Shared let chains like let(:c) { C.new(b) } → let(:b) { B.new(a) } → let(:a) { A.new } buried across nested contexts become impossible to trace. If you need more than one or two levels, consider a factory or a helper method.
One Assertion Per Example
Each it block should test one thing. When a test fails with multiple assertions, you know exactly what broke:
# Bad — multiple assertions, failure message is ambiguous
it "processes the order" do
result = processor.process(order)
expect(result).to be true
expect(order.status).to eq(:completed)
expect(order.completed_at).not_to be_nil
end
# Good — separate examples
it "returns true" do
expect(processor.process(order)).to be true
end
it "marks the order as completed" do
processor.process(order)
expect(order.status).to eq(:completed)
end
it "sets the completion timestamp" do
processor.process(order)
expect(order.completed_at).not_to be_nil
endThe tradeoff is more examples and more setup repetition. Use before blocks to share setup without duplicating it.
There's a pragmatic exception: when testing multiple attributes of a returned value object, bundling them is reasonable — the object is the unit, and validating multiple fields together is one logical assertion:
it "returns complete user data" do
result = UserSerializer.new(user).serialize
expect(result).to include(
id: user.id,
email: user.email,
full_name: user.full_name
)
endCustom Matchers
When you find yourself writing the same complex expectation repeatedly, extract a custom matcher.
aggregate_failures
Before writing a custom matcher, try aggregate_failures — it runs all assertions and reports all failures, not just the first:
it "returns complete user data" do
aggregate_failures do
expect(result[:id]).to eq(user.id)
expect(result[:email]).to eq(user.email)
expect(result[:full_name]).to eq(user.full_name)
end
endWriting Custom Matchers
# spec/support/matchers/be_a_valid_email.rb
RSpec::Matchers.define :be_a_valid_email do
match do |actual|
actual.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
end
failure_message do |actual|
"expected #{actual.inspect} to be a valid email address"
end
failure_message_when_negated do |actual|
"expected #{actual.inspect} not to be a valid email address"
end
endUsage:
expect("alice@example.com").to be_a_valid_email
expect("not-an-email").not_to be_a_valid_emailCompound matchers
RSpec supports composing matchers:
RSpec::Matchers.define :be_a_successful_response do
match do |actual|
actual.status == 200 &&
actual.content_type.include?("application/json") &&
JSON.parse(actual.body).key?("data")
end
failure_message do |actual|
failures = []
failures << "status was #{actual.status} (expected 200)" unless actual.status == 200
failures << "content type was #{actual.content_type}" unless actual.content_type.include?("application/json")
failures << "body missing 'data' key" unless JSON.parse(actual.body).key?("data") rescue failures << "body is not valid JSON"
"expected a successful JSON response but:\n #{failures.join("\n ")}"
end
endit "returns a successful response" do
get "/api/users", headers: auth_headers
expect(response).to be_a_successful_response
endGood custom matchers produce clear failure messages. If you have to read the matcher source to understand a failure, the message isn't good enough.
Avoiding Over-Mocking
Mocking is for isolating your code from external dependencies: HTTP calls, email delivery, payment processors, slow third-party APIs. It is not for isolating your code from other parts of your own application.
Signs you're over-mocking:
- You mock ActiveRecord models in model specs
- You mock service objects in specs for other service objects your code directly instantiates
- Tests pass but production is broken
- Changing an internal interface requires changing 20 mock setups
The rule: mock at the boundary of your system. Inside the boundary (your own code), use real objects. At the boundary (external services, I/O, time), use mocks.
# Bad — mocking internal ActiveRecord
describe OrderProcessor do
let(:order) { instance_double(Order) }
allow(order).to receive(:save!) # Order is your code, not an external dep
end
# Good — let Order be real, mock the payment gateway
describe OrderProcessor do
let(:order) { create(:order) } # real ActiveRecord object
let(:gateway) { instance_double(PaymentGateway) } # external dependency
allow(gateway).to receive(:charge).and_return({ success: true })
endIf your unit test can't use real internal objects without being slow or fragile, the problem is coupling in the design, not insufficient mocking. Fix the design.
Avoid let in favor of explicit setup for complex scenarios
For complex multi-step scenarios, let chains can become hard to follow. Explicit local variables inside the test are sometimes clearer:
# Complex let chain — hard to follow dependencies
describe "refund flow" do
let(:user) { create(:user) }
let(:product) { create(:product, price: 50) }
let(:order) { create(:order, user: user) }
let(:line_item) { create(:line_item, order: order, product: product) }
let(:payment) { create(:payment, order: order, amount: 50) }
it "processes the refund" do
# what state is everything in?
end
end
# Explicit setup — the story is visible
it "processes the refund" do
user = create(:user)
product = create(:product, price: 50)
order = create(:order, user: user, status: :completed)
create(:line_item, order: order, product: product)
payment = create(:payment, order: order, amount: 50, status: :captured)
refund = RefundProcessor.new(order).process
expect(refund.amount).to eq(50)
expect(payment.reload.status).to eq(:refunded)
endFor multi-step integration tests, the inline style tells the complete story. For unit tests with simple setup, let reduces boilerplate.
Performance
A slow test suite is a test suite people stop running. Target under 5 minutes for the full suite on a developer machine.
Measure before optimizing. Run with --profile:
bundle exec rspec --profile 10Shows the 10 slowest examples with their run times. Fix those first.
Prefer build_stubbed over create. This is the biggest win in most Rails suites. build_stubbed doesn't hit the database. Use create only when you need a real database record.
Avoid before(:all) / before(:suite) with database records. Records created in before(:all) survive the transactional rollback. You have to delete them in after(:all). If you miss the cleanup, state leaks between example groups. Better: create per-example with let, or use a seeded database that's set up once.
Use FactoryBot.build_stubbed_list for collections. When testing code that works with collections, build_stubbed_list(:user, 10) is much faster than create_list.
Keep system specs minimal. System specs with a real browser are 10-50x slower than request specs. Use them for critical user journeys only. Test the same logic with faster specs.
Disable Devise lockable/confirmable in tests. Devise's lockable checks database for every failed authentication. Disable it for tests:
# spec/support/devise.rb
RSpec.configure do |config|
config.before(:each, type: :request) do
Devise.mappings[:user].lock_strategy_enabled?(:failed_attempts) && (Devise.lock_after_strategy = :none)
end
endParallelize. gem 'parallel_tests' with separate databases per test process. Halves suite time on a dual-core machine, more on larger machines.
Consistent Structure per Spec
Pick a structure and apply it consistently across the team:
describe SomeClass, type: :model do
# Describe the class-level or module-level behavior first
describe "validations" do
# shoulda-matchers one-liners
end
describe "associations" do
# shoulda-matchers one-liners
end
describe "scopes" do
describe ".scope_name" do
let!(:matching) { create(...) }
let!(:non_matching) { create(...) }
it "returns only matching records" do ...
end
end
describe "#instance_method" do
subject(:result) { object.instance_method }
context "when condition A" do
let(:object) { ... }
it { is_expected.to ... }
end
context "when condition B" do
let(:object) { ... }
it { is_expected.to ... }
end
end
endStructure isn't just aesthetic. When every spec file follows the same pattern, you can scan any spec quickly and find what you need. Novelty in spec structure is a maintenance cost.
Summary Checklist
describeuses class/method names with#and.prefixescontextstarts with "when", "with", "without", or "given"itcompletes the sentence "it ___" with behavior, not implementation- Use
letoverbefore+ instance variables - Use
let!only when the side effect must happen regardless - One logical assertion per example (use
aggregate_failureswhen bundling makes sense) - Extract repeated complex expectations into custom matchers
- Mock external dependencies; use real objects for your own code
- Prefer
build_stubbedovercreatein unit tests - Profile and fix slow specs before the suite becomes a burden