RSpec Mocking and Stubbing: Doubles, Stubs, and Message Expectations
Mocking and stubbing are how you isolate the thing you're testing from everything it depends on. Without them, a unit test that touches a database, an external API, or a slow service stops being a unit test — it becomes an integration test wearing a unit test costume.
RSpec ships with a full mocking library called rspec-mocks. It's included automatically when you add the rspec gem. This post covers everything in it: plain doubles, verifying doubles, allow, expect, the spy pattern, and partial doubles.
The Difference Between Stubs and Mocks
These terms get conflated constantly, so let's define them:
- Stub: Replace a method with a fixed return value. You don't care if it was called — you just need it to return something predictable.
- Mock: Set an expectation that a method will be called, with specific arguments. The test fails if the call never happens.
RSpec uses allow for stubs and expect for mocks. Both use the same receive matcher syntax.
Plain Doubles
A double is a test object that stands in for a real object. It has no behavior unless you define it:
user = double("User")The string argument is just a label for error messages. Now give it some behavior:
user = double("User", name: "Alice", admin?: false)
expect(user.name).to eq("Alice")
expect(user.admin?).to be falseYou can also define methods with allow:
user = double("User")
allow(user).to receive(:name).and_return("Alice")
allow(user).to receive(:save).and_return(true)Calling any method on a plain double that hasn't been stubbed raises an error:
user = double("User")
user.email # => RSpec::Mocks::MockExpectedMessageNotReceivedThis is intentional. Doubles are strict by default.
allow — Stubbing Methods
allow stubs a method without setting any expectation about whether it gets called:
allow(user).to receive(:name).and_return("Alice")Return values
# Return a fixed value
allow(service).to receive(:fetch).and_return({ status: "ok" })
# Return different values on successive calls
allow(service).to receive(:fetch).and_return("first", "second", "third")
# Compute the return value from arguments
allow(service).to receive(:find).and_return { |id| { id: id, name: "User #{id}" } }Stubbing with argument constraints
allow(cache).to receive(:get).with("user:1").and_return(user_data)
allow(cache).to receive(:get).with("user:2").and_return(nil)
allow(cache).to receive(:get).with(anything).and_return(nil) # catch-allRaising exceptions
allow(api_client).to receive(:get).and_raise(Faraday::TimeoutError)
allow(db).to receive(:query).and_raise(ActiveRecord::StatementInvalid, "connection lost")Calling the original implementation
allow(user).to receive(:name).and_call_originalUseful when you only want to observe calls but don't want to change behavior.
expect — Message Expectations (Mocks)
expect works like allow but also asserts the method was called. If the method isn't called during the example, the test fails:
expect(mailer).to receive(:send_email).with("alice@example.com")The expectation is verified at the end of the example automatically.
Call count expectations
expect(cache).to receive(:set).once
expect(logger).to receive(:warn).twice
expect(service).to receive(:retry).exactly(3).times
expect(tracker).to receive(:log).at_least(:once)
expect(tracker).to receive(:log).at_least(2).times
expect(backup).to receive(:run).at_most(:once)Argument matchers
expect(api).to receive(:post).with("/users", hash_including(name: "Alice"))
expect(logger).to receive(:info).with(a_string_matching(/user created/))
expect(service).to receive(:process).with(anything)
expect(service).to receive(:process).with(no_args)
expect(db).to receive(:query).with(Integer, String)hash_including is particularly useful — it lets you assert a subset of a hash without specifying every key:
expect(api).to receive(:post).with(
"/orders",
hash_including(status: "pending", user_id: 42)
)Ordered expectations
When call order matters:
expect(service).to receive(:start).ordered
expect(service).to receive(:process).ordered
expect(service).to receive(:finish).orderedVerifying Doubles
Plain doubles let you stub any method, even ones that don't exist on the real class. This is dangerous — your stubs can diverge from the real interface and your tests pass while production breaks.
Verifying doubles solve this. They check that stubbed methods actually exist on the class being doubled:
user = instance_double(User)If you stub a method that doesn't exist on User, you get an error immediately:
user = instance_double(User)
allow(user).to receive(:nonexistent_method)
# => RSpec::Mocks::MockExpectedMessageNotReceived:
# User does not implement: nonexistent_methodVerifying doubles also check argument signatures. If User#update takes one argument and you stub it with two, the test fails.
Types of verifying doubles
# For instances of a class
user = instance_double(User)
user = instance_double(User, name: "Alice", email: "alice@example.com")
# For the class itself (class methods)
user_class = class_double(User)
allow(user_class).to receive(:find).with(1).and_return(user)
# For modules
serializer = object_double(UserSerializer.new)Use verifying doubles by default. Only fall back to plain doubles when the real class isn't available (e.g., testing against a third-party API that you don't have loaded in test).
Partial Doubles
Instead of creating a standalone double, you can stub methods directly on real objects:
user = User.new(name: "Alice")
allow(user).to receive(:admin?).and_return(true)
expect(user.admin?).to be true # returns stubbed value
expect(user.name).to eq("Alice") # real method still worksThis is useful when you need most of an object's real behavior but want to override one specific method. RSpec restores the original behavior after each example.
You can also stub class methods:
allow(User).to receive(:find).with(99).and_return(nil)
allow(Time).to receive(:now).and_return(Time.parse("2024-01-15 10:00:00"))The Time.now stub is extremely common for testing time-dependent code.
The Spy Pattern
Regular mocks require you to set the expectation before the code runs. This can feel awkward when you want to call the code first and verify afterward:
# Mock-style (expectation before action)
expect(notifier).to receive(:notify).with("payment received")
order.process_payment(100)
# Spy-style (expectation after action) — more natural for some cases
allow(notifier).to receive(:notify)
order.process_payment(100)
expect(notifier).to have_received(:notify).with("payment received")have_received verifies the call after the fact. It works on anything you've used allow with, or on dedicated spy objects:
notifier = spy("Notifier") # spy accepts any message
order.process_payment(100)
expect(notifier).to have_received(:notify).with("payment received")A spy is a double that allows any method call by default (returns nil for everything unless stubbed). It's useful when you want to observe an object without caring about most of its behavior.
For verifying spies:
notifier = instance_spy(Notifier) # verifying spy
order.process_payment(100)
expect(notifier).to have_received(:notify)Real-World Example: Testing a Service Object
Here's a practical example that ties everything together. We're testing an OrderProcessor that depends on a payment gateway and a notification service:
# app/services/order_processor.rb
class OrderProcessor
def initialize(payment_gateway, notifier)
@payment_gateway = payment_gateway
@notifier = notifier
end
def process(order)
result = @payment_gateway.charge(order.amount, order.payment_method)
if result[:success]
order.update!(status: :completed)
@notifier.notify(order.user, "Your order has been processed")
true
else
order.update!(status: :failed)
@notifier.notify(order.user, "Payment failed: #{result[:error]}")
false
end
end
end# spec/services/order_processor_spec.rb
describe OrderProcessor do
let(:gateway) { instance_double(PaymentGateway) }
let(:notifier) { instance_double(NotificationService) }
let(:processor) { OrderProcessor.new(gateway, notifier) }
let(:order) { instance_double(Order, amount: 99.99, payment_method: "card", user: user) }
let(:user) { instance_double(User) }
describe "#process" do
context "when payment succeeds" do
before do
allow(gateway).to receive(:charge)
.with(99.99, "card")
.and_return({ success: true })
allow(order).to receive(:update!).with(status: :completed)
allow(notifier).to receive(:notify)
end
it "returns true" do
expect(processor.process(order)).to be true
end
it "marks the order as completed" do
expect(order).to receive(:update!).with(status: :completed)
processor.process(order)
end
it "notifies the user" do
processor.process(order)
expect(notifier).to have_received(:notify)
.with(user, "Your order has been processed")
end
end
context "when payment fails" do
before do
allow(gateway).to receive(:charge)
.and_return({ success: false, error: "insufficient funds" })
allow(order).to receive(:update!).with(status: :failed)
allow(notifier).to receive(:notify)
end
it "returns false" do
expect(processor.process(order)).to be false
end
it "marks the order as failed" do
expect(order).to receive(:update!).with(status: :failed)
processor.process(order)
end
it "notifies the user with the error" do
processor.process(order)
expect(notifier).to have_received(:notify)
.with(user, "Payment failed: insufficient funds")
end
end
context "when the gateway raises an exception" do
before do
allow(gateway).to receive(:charge).and_raise(PaymentGateway::ConnectionError)
end
it "propagates the error" do
expect { processor.process(order) }.to raise_error(PaymentGateway::ConnectionError)
end
end
end
endNotice: we used instance_double throughout, not plain doubles. We used allow in before blocks for setup, and expect(...).to have_received for assertions about side effects. The spy pattern (allow + have_received) keeps the assertion in the it block where it belongs.
Common Mistakes
Stubbing what you don't own. Avoid stubbing methods on objects you don't control (like ActiveRecord or third-party gems directly). Wrap external dependencies in your own adapter classes and stub those.
Too many mocks. If a test has five or more allow lines, your object under test probably has too many dependencies. That's a design signal, not a test problem.
Using plain doubles when verifying doubles would catch the bug. Always prefer instance_double, class_double, and object_double.
Relying on mock order. If your test breaks when you reorder the assertions, your test is too tightly coupled to implementation details.
Forgetting allow before expect(...).to have_received. You must allow the method first (or use spy), otherwise the call won't be intercepted and have_received won't see it.
Global Stubs in spec_helper
Sometimes you want to stub something in every example — like preventing any real HTTP calls:
RSpec.configure do |config|
config.before(:each) do
allow(HTTPClient).to receive(:get).and_raise("Real HTTP calls not allowed in tests")
end
endOr use a gem like webmock that blocks all HTTP by default and lets you configure stubs per test.
The goal is always the same: make tests fast, deterministic, and isolated. Mocks and stubs are the primary tool for achieving that.