RSpec Best Practices: Naming, subject/let, Custom Matchers, and Performance

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
end

The # 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" do

Never: 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 status

If 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 }
end

Name 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
end

Use 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) }
end

Rules 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(...) end

Name 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)
end

Don'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
end

The 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
  )
end

Custom 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
end

Writing 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
end

Usage:

expect("alice@example.com").to be_a_valid_email
expect("not-an-email").not_to be_a_valid_email

Compound 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
end
it "returns a successful response" do
  get "/api/users", headers: auth_headers
  expect(response).to be_a_successful_response
end

Good 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 })
end

If 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)
end

For 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 10

Shows 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
end

Parallelize. 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
end

Structure 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

  • describe uses class/method names with # and . prefixes
  • context starts with "when", "with", "without", or "given"
  • it completes the sentence "it ___" with behavior, not implementation
  • Use let over before + instance variables
  • Use let! only when the side effect must happen regardless
  • One logical assertion per example (use aggregate_failures when bundling makes sense)
  • Extract repeated complex expectations into custom matchers
  • Mock external dependencies; use real objects for your own code
  • Prefer build_stubbed over create in unit tests
  • Profile and fix slow specs before the suite becomes a burden

Read more

Start now free