When writing tests in RSpec, especially in modern Rails 7+ apps with Ruby 3+, understanding test doubles, stubs, and mocks is essential for writing clean, fast, and maintainable tests.
In this guide, we’ll break down:
- What are doubles, stubs, and mocks
- When to use each
- Common RSpec methods (
let,let!,subject,allow,expect) - Real-world Rails examples (controllers, services, serializers)
- Best practices and pitfalls
Why do we need test doubles?
In real applications, your code interacts with:
- External APIs
- Databases
- Background jobs
- Third-party services (Stripe, Redis, etc.)
Testing all of these directly makes tests:
- Slow
- Fragile
- Hard to isolate
Test doubles solve this by replacing real dependencies with controlled, predictable behavior.
1. Test Double – The Foundation
What is a double?
A double is a fake object that stands in for a real one.
let(:user) { double('User', name: 'Adam') }it 'returns user name' do expect(user.name).to eq('Adam')end
instance_double (Recommended)
let(:user) { instance_double(User, name: 'Adam') }
Why better?
- Verifies methods exist on real class
- Prevents typos
Rule:
Use
instance_doubleoverdoublewhenever possible
2. Stub — Controlling Behavior
What is a stub?
A stub defines what a method should return.
allow(user).to receive(:admin?).and_return(true)
You are saying:
“If
admin?is called, returntrue.”
Rails Example
class DiscountService def initialize(user) @user = user end def call @user.admin? ? 50 : 10 endend
Spec:
describe DiscountService do let(:user) { instance_double(User) } it 'returns 50 for admin user' do allow(user).to receive(:admin?).and_return(true) result = described_class.new(user).call expect(result).to eq(50) endend
Key idea:
- Stub = control output
- Does NOT verify method is called
3. Mock — Verifying Behavior
What is a mock?
A mock verifies that a method was called.
expect(service).to receive(:call)
You are saying:
“This method MUST be called.”
Rails Example (Service interaction)
class OrderProcessor def initialize(payment_gateway) @payment_gateway = payment_gateway end def call(amount) @payment_gateway.charge(amount) endend
Spec:
describe OrderProcessor do let(:gateway) { instance_double('PaymentGateway') } it 'charges the payment gateway' do expect(gateway).to receive(:charge).with(1000) described_class.new(gateway).call(1000) endend
Key idea:
- Mock = verify interaction
- Test fails if method is NOT called
4. Stub + Real Method → .and_call_original
Hybrid approach
expect(User).to receive(:find).and_call_original
Meaning:
- Verify method is called ✅
- Execute real implementation ✅
Rails Example
expect(Serializers::ProductInfo).to receive(:new).with( product: product, date: Date.today).and_call_original
Use carefully:
- Tests implementation, not behavior
- Can become brittle
5. let vs let!
let (lazy)
let(:user) { create(:user) }
- Runs only when used
let! (eager)
let!(:user) { create(:user) }
- Runs before each test
Example
let!(:recipes) { create_list(:recipe, 3) }it 'returns recipes' do get '/recipes' expect(JSON.parse(response.body).size).to eq(3)end
Rule:
- Use
letby default - Use
let!when DB setup must happen before request
6. subject — Defining the Action
subject(:request) { get '/api/v1/home/homepage' }
Usage
it 'returns 200' do request expect(response).to have_http_status(:ok)end
Benefits:
- Reusable
- Lazy
- Override in contexts
7. allow_any_instance_of (⚠️ Avoid if possible)
allow_any_instance_of(User).to receive(:admin?).and_return(true)
Problem:
- Affects ALL instances
- Hard to debug
- Breaks isolation
Better:
allow(user).to receive(:admin?).and_return(true)
8. Real Rails Example (Controller + Service)
Controller
class OrdersController < ApplicationController def create order = OrderBuilder.new(params).create render json: { id: order.id } endend
Spec
describe 'POST /orders' do let(:mock_order) { instance_double(Order, id: 123) } let(:builder) { instance_double(OrderBuilder) } before do allow(OrderBuilder).to receive(:new).and_return(builder) allow(builder).to receive(:create).and_return(mock_order) end it 'returns order id' do post '/orders', params: { name: 'Test' } expect(response).to have_http_status(:ok) expect(JSON.parse(response.body)['id']).to eq(123) endend
Summary Table
| Concept | Method | Purpose |
|---|---|---|
| Double | double, instance_double | Fake object |
| Stub | allow(...).to receive | Control return value |
| Mock | expect(...).to receive | Verify method call |
| Hybrid | .and_call_original | Verify + run real code |
| Lazy setup | let | Run when needed |
| Eager setup | let! | Run before test |
| Action | subject | Define main execution |
Common Pitfalls
Over-mocking
- Tests break on refactor
- Tests implementation, not behavior
Using allow_any_instance_of
- Global side effects
- Avoid unless absolutely necessary
Too many let!
- Slower tests
- Hidden setup
Best Practices
- Prefer behavior testing over implementation
- Use
instance_doubleinstead ofdouble - Keep tests readable like English
- Use shared_context for repeated setup
- Avoid overusing mocks
Final Thought
Think of RSpec like this:
- Double → Fake object
- Stub → “Return this value”
- Mock → “This must be called”
Mastering these will make your Rails tests:
- Faster ⚡
- Cleaner 🧼
- More reliable 🧪
Happy Testing!