RSpec Mocks and Doubles: Stubs, Spies, and Message Expectations

RSpec Mocks and Doubles: Stubs, Spies, and Message Expectations

Test doubles let you isolate the unit under test by replacing collaborators with controlled stand-ins. RSpec ships with a full mocking library — no extra gems needed. This guide covers everything from basic stubs to strict message expectations.

The Four Types of Test Doubles

Type Purpose
Double Pure stand-in — no real object behind it
Instance double Verified double for a specific class
Stub Replaces a method with a fixed return value
Spy Records calls so you can assert after the fact

Creating a Double

RSpec.describe OrderProcessor do
  it "sends a confirmation email" do
    mailer = double("OrderMailer")
    allow(mailer).to receive(:send_confirmation)

    processor = OrderProcessor.new(mailer: mailer)
    processor.process(order)

    expect(mailer).to have_received(:send_confirmation)
  end
end

double("label") creates an anonymous object. Calling any method not explicitly allowed raises RSpec::Mocks::MockExpectationError — which catches typos early.

Stubbing Methods with allow

Use allow when you need a collaborator to return something but the call itself is not what you're testing:

allow(payment_gateway).to receive(:charge).and_return({ status: "success" })
allow(user).to receive(:admin?).and_return(true)

Chaining return values

allow(queue).to receive(:pop).and_return("first", "second", nil)

Each call consumes the next value. After the list is exhausted, the last value repeats.

Raising errors

allow(api_client).to receive(:fetch).and_raise(Net::ReadTimeout)

Yielding to a block

allow(cache).to receive(:fetch).and_yield

Message Expectations with expect(...).to receive

Use expect when the call itself is the behavior you are testing:

expect(mailer).to receive(:send_confirmation).with(order.id)
processor.process(order)

The expectation is verified at the end of the example — if send_confirmation is never called, the test fails.

Argument matchers

expect(logger).to receive(:info).with(/payment processed/)
expect(api).to receive(:post).with(hash_including(amount: 100))
expect(service).to receive(:call).with(anything)
expect(handler).to receive(:process).with(no_args)

Call count constraints

expect(mailer).to receive(:send_confirmation).once
expect(cache).to receive(:invalidate).exactly(3).times
expect(logger).to receive(:warn).at_least(:twice)
expect(metric).to receive(:increment).at_most(5).times

Verified Doubles

Plain doubles don't check whether the method actually exists on the real class. Verified doubles do:

mailer = instance_double(OrderMailer)
allow(mailer).to receive(:send_confirmation)  # passes
allow(mailer).to receive(:typo_method)        # raises — method doesn't exist

Always prefer verified doubles in new tests. They catch interface drift automatically.

# class double — for class-level methods
repo = class_double(UserRepository)
allow(repo).to receive(:find_by_email)

# object double — wraps an existing instance
config = object_double(AppConfig.instance)
allow(config).to receive(:feature_enabled?).and_return(true)

Spies

A spy allows any message by default and lets you assert after the code runs:

mailer = spy("OrderMailer")

processor.process(order)

expect(mailer).to have_received(:send_confirmation).with(order.id)

spy is syntactic sugar for double with as_null_object. Use it when you want to observe interactions without setting upfront expectations.

Partial Doubles (Stubbing Real Objects)

Sometimes you need to stub one method on a real object without replacing the whole thing:

allow(User).to receive(:find).and_return(user_fixture)
allow(Time).to receive(:now).and_return(frozen_time)

RSpec restores the original method after each example automatically.

Scoping with and_call_original

Let most calls through but intercept specific ones:

allow(api_client).to receive(:get).and_call_original
allow(api_client).to receive(:get).with("/health").and_return({ status: "ok" })

Common Patterns

Injecting doubles via constructor

class OrderProcessor
  def initialize(mailer: OrderMailer.new)
    @mailer = mailer
  end
end

# In tests:
mailer = instance_double(OrderMailer)
processor = OrderProcessor.new(mailer: mailer)

Constructor injection makes tests straightforward — no global stubbing required.

Avoiding over-mocking

Mock collaborators, not the system under test. If you find yourself mocking methods on the object you're testing, that's a smell — the design may need refactoring.

# Bad — mocking the SUT
allow(order_processor).to receive(:calculate_tax).and_return(10)

# Good — mock the tax service the processor depends on
allow(tax_service).to receive(:calculate).and_return(10)

Configuring Mock Verification

In spec/support/rspec_config.rb:

RSpec.configure do |config|
  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true  # enables verified doubles for stubs on real objects
  end
end

verify_partial_doubles is the most impactful setting — it catches stubs on methods that don't exist on the real class.

Monitoring Tests in Production

Test doubles keep unit tests fast and focused, but they can drift from the real implementations they replace. HelpMeTest runs your integration and end-to-end test suite continuously — so a stubbed payment gateway that diverges from the real API gets caught in CI before it reaches production. Sign up at helpmetest.com and connect your test suite for 24/7 monitoring.

Read more

Start now free