Mastering RSpec Test Doubles in Rails 7+ (Ruby 3+)

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_double over double whenever 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, return true.”


Rails Example

class DiscountService
def initialize(user)
@user = user
end
def call
@user.admin? ? 50 : 10
end
end

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

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

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

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 let by 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 }
end
end

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

Summary Table

ConceptMethodPurpose
Doubledouble, instance_doubleFake object
Stuballow(...).to receiveControl return value
Mockexpect(...).to receiveVerify method call
Hybrid.and_call_originalVerify + run real code
Lazy setupletRun when needed
Eager setuplet!Run before test
ActionsubjectDefine 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_double instead of double
  • 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!

Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a comment