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
enddouble("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_yieldMessage 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).timesVerified 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 existAlways 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
endverify_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.