CrackedRuby CrackedRuby

Test Doubles (Mocks, Stubs, Spies)

Overview

Test doubles replace real objects in tests to control dependencies, isolate code under test, and verify interactions. The term "test double" comes from the film industry, where stunt doubles stand in for actors during dangerous scenes. In testing, doubles stand in for production code that would otherwise complicate or slow down tests.

Three primary types of test doubles serve distinct purposes:

Stubs return predetermined values when called. Tests use stubs to set up the state needed for assertions without executing real logic. A stub replaces a database query with a canned response, avoiding database setup.

Mocks verify that specific methods receive expected calls with correct arguments. Tests configure mocks with expectations before execution and verify those expectations after. Mocks fail tests when expected interactions don't occur.

Spies record all method calls for later inspection. Unlike mocks that fail immediately on unexpected calls, spies passively observe and let tests query what happened. Spies work well when checking interactions after the fact.

Test doubles address several testing challenges. Real dependencies may be slow (external APIs), unreliable (network calls), expensive (payment processing), or difficult to trigger (error conditions). Doubles eliminate these issues by replacing real implementations with test-controlled versions.

# Without test double - slow, fragile, requires network
def test_weather_alert
  api = WeatherAPI.new
  result = api.fetch_forecast('Chicago')  # Real API call
  assert result.include?('sunny')
end

# With stub - fast, reliable, no dependencies
def test_weather_alert
  api = stub(fetch_forecast: 'sunny weather expected')
  assert api.fetch_forecast('Chicago').include?('sunny')
end

The distinction between test double types matters because each serves different verification needs. Stubs answer questions about state ("what does this return?"), mocks verify behavior ("was this method called?"), and spies inspect interactions ("what happened during execution?").

Key Principles

Test doubles operate on the principle of substitution - replacing real objects with controlled alternatives that respond predictably. This substitution requires that doubles implement the same interface as the objects they replace, whether through duck typing or explicit interface definition.

State vs Behavior Verification represents the core distinction in testing philosophy. State verification checks the result of operations - what values exist after execution. Behavior verification checks the interactions during execution - which methods were called with what arguments. Stubs support state verification by providing return values. Mocks and spies support behavior verification by tracking method calls.

# State verification with stub
stub_repo = stub(find: User.new(name: 'Alice'))
service = UserService.new(stub_repo)
result = service.get_user(1)
assert_equal 'Alice', result.name  # Verifying state

# Behavior verification with mock
mock_repo = mock
mock_repo.expects(:find).with(1).returns(User.new)
service = UserService.new(mock_repo)
service.get_user(1)
mock_repo.verify  # Verifying behavior

Interface Segregation in test doubles means doubles should only implement the methods required by the test. Overspecifying double behavior creates maintenance burden and couples tests to implementation details. A focused double that responds to two methods proves easier to maintain than a comprehensive fake implementing dozens of methods.

Isolation Boundaries define what to replace with doubles. Testing a single class typically means replacing its direct collaborators. Testing an integration of multiple classes means replacing external dependencies but preserving internal interactions. The isolation level determines which objects become doubles and which remain real.

Verification Granularity affects test brittleness. Fine-grained verification checks exact argument values and call counts. Coarse-grained verification checks that interactions occurred without specifying details. Fine-grained tests catch more bugs but break more often during refactoring. Coarse-grained tests survive refactoring but may miss subtle defects.

# Fine-grained - brittle but specific
mock.expects(:save).with(name: 'Bob', age: 30, city: 'NYC')

# Coarse-grained - flexible but vague  
mock.expects(:save).with(kind_of(Hash))

Test Doubles vs Fakes differ in purpose and complexity. Test doubles provide minimal implementations focused on specific test scenarios. Fakes provide working implementations with shortcuts - an in-memory database instead of PostgreSQL. Doubles verify interactions; fakes verify logic against simplified systems.

Dependency Injection enables test double substitution by accepting dependencies as parameters rather than creating them internally. Without injection, code that instantiates its own dependencies cannot use doubles. Injection makes classes configurable and testable.

# Hard to test - creates own dependency
class OrderProcessor
  def process(order)
    repo = OrderRepository.new  # Fixed dependency
    repo.save(order)
  end
end

# Easy to test - accepts dependency
class OrderProcessor
  def initialize(repo)
    @repo = repo
  end
  
  def process(order)
    @repo.save(order)  # Injected dependency
  end
end

Partial Doubles stub some methods on real objects while leaving other methods operational. This technique helps when only specific methods need control, avoiding the overhead of replacing entire objects. Partial doubles carry risk - mixing real and stubbed behavior can create confusing failures.

Ruby Implementation

Ruby testing frameworks implement test doubles through dynamic method definition and method interception. The language's open classes and method_missing capabilities make creating doubles straightforward compared to statically-typed languages.

RSpec Doubles provide the most comprehensive test double implementation in Ruby. RSpec distinguishes between pure doubles (completely fake objects) and partial doubles (real objects with stubbed methods).

# Pure double - responds only to configured methods
user_double = double('user')
allow(user_double).to receive(:name).and_return('Alice')
allow(user_double).to receive(:email).and_return('alice@example.com')

# Verifying doubles - ensure methods exist on real class
user_double = instance_double('User')
allow(user_double).to receive(:name).and_return('Alice')
# This would fail if User doesn't have a 'name' method

# Null object - responds to any method with itself
null_user = double('user').as_null_object
null_user.any_method.another_method.chain  # All return the double

RSpec's allow syntax creates stubs, while expect creates mocks with verification. The framework distinguishes method stubs (return values) from message expectations (required calls).

# Stub - setup only, no verification
allow(calculator).to receive(:add).and_return(10)
result = calculator.add(5, 5)  # Returns 10, no verification

# Mock - setup with verification
expect(calculator).to receive(:add).with(5, 5).and_return(10)
result = calculator.add(5, 5)  # Must be called exactly this way

# Spy - verify after execution
allow(calculator).to receive(:add)
calculator.add(5, 5)
expect(calculator).to have_received(:add).with(5, 5)

Minitest Mocks take a different approach with explicit mock objects and verification methods. Minitest mocks require manual verification and use a more verbose syntax.

require 'minitest/mock'

def test_user_service
  mock_repo = Minitest::Mock.new
  mock_repo.expect(:find, User.new(name: 'Bob'), [1])
  
  service = UserService.new(mock_repo)
  user = service.get_user(1)
  
  assert_equal 'Bob', user.name
  mock_repo.verify  # Fails if expectations not met
end

Minitest supports stubbing through the stub method, which temporarily replaces methods during block execution.

def test_with_stub
  user = User.new
  user.stub(:admin?, true) do
    assert user.admin?
  end
  # Original method restored after block
end

Method Stubbing Mechanics in Ruby involve replacing method definitions temporarily. RSpec and Minitest both store original method definitions and restore them after tests complete, preventing test pollution.

# RSpec internal mechanism (simplified)
original_method = object.method(:method_name)
object.define_singleton_method(:method_name) do |*args|
  # Stubbed behavior
end
# Restore after test
object.define_singleton_method(:method_name, original_method)

Class-Level Doubles stub methods on classes themselves rather than instances, useful for testing factory methods or class-level APIs.

# Stubbing class methods
allow(User).to receive(:find).and_return(User.new(name: 'Alice'))
user = User.find(1)  # Returns stubbed user

# Stubbing constants
stub_const('API_KEY', 'test_key_123')
assert_equal 'test_key_123', API_KEY

Argument Matchers provide flexible verification without requiring exact argument matches. Ruby's dynamic typing makes matchers particularly valuable.

# Exact match
expect(service).to receive(:send_email).with('alice@example.com', 'subject')

# Type match
expect(service).to receive(:send_email).with(kind_of(String), anything)

# Pattern match
expect(service).to receive(:send_email).with(/.*@example\.com/, anything)

# Custom matcher
expect(service).to receive(:process).with(
  hash_including(status: 'pending')
)

Multiple Return Values simulate changing behavior across multiple calls, useful for testing retry logic or progressive states.

allow(api).to receive(:fetch).and_return(nil, nil, 'data')
api.fetch  # => nil
api.fetch  # => nil  
api.fetch  # => 'data'

# Raising then succeeding
allow(api).to receive(:fetch)
  .and_raise(NetworkError)
  .and_return('data')

Practical Examples

Example 1: Email Service Testing

Testing code that sends emails presents challenges - tests shouldn't actually send emails, yet need to verify the email service receives correct data.

class OrderConfirmation
  def initialize(mailer)
    @mailer = mailer
  end
  
  def send_for(order)
    @mailer.send_email(
      to: order.customer_email,
      subject: "Order ##{order.id} Confirmed",
      body: generate_body(order)
    )
  end
  
  private
  
  def generate_body(order)
    "Thank you for order ##{order.id}"
  end
end

# Test with mock - verify behavior
RSpec.describe OrderConfirmation do
  it 'sends confirmation email with correct details' do
    mailer = double('mailer')
    order = double('order', id: 123, customer_email: 'customer@example.com')
    
    expect(mailer).to receive(:send_email).with(
      to: 'customer@example.com',
      subject: 'Order #123 Confirmed',
      body: include('order #123')
    )
    
    confirmation = OrderConfirmation.new(mailer)
    confirmation.send_for(order)
  end
end

This test verifies the mailer receives the send_email call with correct parameters without sending actual emails. The mock fails the test if send_email isn't called or receives wrong arguments.

Example 2: External API Integration

Testing code that calls external APIs requires isolating from network dependencies while preserving request/response logic.

class WeatherService
  def initialize(api_client)
    @api_client = api_client
  end
  
  def forecast_for(city)
    response = @api_client.get("/weather/#{city}")
    return nil if response.nil?
    
    {
      temperature: response['temp'],
      conditions: response['conditions'],
      city: city
    }
  rescue APIError
    { error: 'Service unavailable' }
  end
end

# Test with stub - control responses
RSpec.describe WeatherService do
  it 'returns formatted forecast data' do
    api_client = double('api_client')
    allow(api_client).to receive(:get)
      .with('/weather/Chicago')
      .and_return({ 'temp' => 72, 'conditions' => 'sunny' })
    
    service = WeatherService.new(api_client)
    forecast = service.forecast_for('Chicago')
    
    expect(forecast[:temperature]).to eq(72)
    expect(forecast[:conditions]).to eq('sunny')
    expect(forecast[:city]).to eq('Chicago')
  end
  
  it 'handles API errors gracefully' do
    api_client = double('api_client')
    allow(api_client).to receive(:get).and_raise(APIError)
    
    service = WeatherService.new(api_client)
    forecast = service.forecast_for('Chicago')
    
    expect(forecast[:error]).to eq('Service unavailable')
  end
end

Stubs control API responses without network calls. Tests verify response parsing and error handling using predetermined data.

Example 3: Retry Logic Testing

Code that retries failed operations needs tests for retry behavior without waiting for actual retries.

class DataSynchronizer
  def initialize(remote_service, max_retries: 3)
    @remote_service = remote_service
    @max_retries = max_retries
  end
  
  def sync(data)
    retries = 0
    begin
      @remote_service.upload(data)
      true
    rescue TransientError => e
      retries += 1
      retry if retries < @max_retries
      false
    end
  end
end

# Test with spy - verify retry attempts
RSpec.describe DataSynchronizer do
  it 'retries on transient errors' do
    remote = double('remote_service')
    
    # First two calls fail, third succeeds
    allow(remote).to receive(:upload)
      .and_raise(TransientError)
      .and_raise(TransientError)
      .and_return(true)
    
    sync = DataSynchronizer.new(remote)
    result = sync.sync('test data')
    
    expect(result).to be true
    expect(remote).to have_received(:upload).exactly(3).times
  end
  
  it 'stops after max retries' do
    remote = double('remote_service')
    allow(remote).to receive(:upload).and_raise(TransientError)
    
    sync = DataSynchronizer.new(remote, max_retries: 2)
    result = sync.sync('test data')
    
    expect(result).to be false
    expect(remote).to have_received(:upload).exactly(2).times
  end
end

The spy records upload attempts, verifying retry count without time delays. Multiple return values simulate progressive failure and success.

Example 4: Callback Verification

Testing that code invokes callbacks at appropriate times requires mocks to verify callback execution.

class PaymentProcessor
  def initialize(gateway)
    @gateway = gateway
  end
  
  def process(payment, on_success:, on_failure:)
    result = @gateway.charge(payment.amount, payment.card)
    
    if result.success?
      on_success.call(result.transaction_id)
    else
      on_failure.call(result.error_message)
    end
  end
end

# Test with mock callbacks
RSpec.describe PaymentProcessor do
  it 'invokes success callback on successful payment' do
    gateway = double('gateway')
    allow(gateway).to receive(:charge)
      .and_return(double(success?: true, transaction_id: 'txn_123'))
    
    success_callback = double('callback')
    failure_callback = double('callback')
    
    expect(success_callback).to receive(:call).with('txn_123')
    expect(failure_callback).not_to receive(:call)
    
    processor = PaymentProcessor.new(gateway)
    payment = double('payment', amount: 100, card: 'card_token')
    
    processor.process(payment, 
      on_success: success_callback,
      on_failure: failure_callback
    )
  end
end

Mock callbacks verify execution and arguments. The test ensures only the appropriate callback fires based on payment result.

Common Patterns

Dependency Injection Pattern makes test doubles usable by accepting dependencies through constructor parameters or setter methods rather than hardcoding them.

# Anti-pattern - hardcoded dependency
class UserManager
  def create_user(params)
    repo = UserRepository.new  # Cannot inject double
    repo.save(User.new(params))
  end
end

# Pattern - injected dependency
class UserManager
  def initialize(repo = UserRepository.new)
    @repo = repo
  end
  
  def create_user(params)
    @repo.save(User.new(params))
  end
end

# Testing with injected double
repo_double = double('repository')
expect(repo_double).to receive(:save)
manager = UserManager.new(repo_double)
manager.create_user(name: 'Alice')

Factory Pattern for Doubles centralizes double creation when multiple tests need similar test doubles, reducing duplication and improving maintainability.

module TestFactories
  def mock_user(overrides = {})
    defaults = {
      id: 1,
      name: 'Test User',
      email: 'test@example.com',
      admin?: false
    }
    
    user = double('user')
    defaults.merge(overrides).each do |method, value|
      allow(user).to receive(method).and_return(value)
    end
    user
  end
end

RSpec.configure do |config|
  config.include TestFactories
end

# Usage in tests
it 'processes admin users differently' do
  admin = mock_user(admin?: true)
  regular = mock_user(admin?: false)
  # Test with both types
end

Test-Specific Subclass Pattern creates real subclasses with stubbed behavior for dependencies that need partial real functionality.

class TestDatabase < Database
  def initialize
    # Skip actual database connection
  end
  
  def execute(query)
    # Return test data without hitting database
    case query
    when /SELECT.*users/
      [{ id: 1, name: 'Test User' }]
    else
      []
    end
  end
end

# Use in tests without complex mocking
db = TestDatabase.new
results = db.execute('SELECT * FROM users')

Spy-First Pattern uses spies instead of mocks for more flexible verification, allowing tests to verify interactions after execution rather than setting up expectations beforehand.

# Mock-first approach - brittle
it 'sends notification' do
  notifier = double('notifier')
  expect(notifier).to receive(:send).with(kind_of(Hash))
  service = Service.new(notifier)
  service.process
end

# Spy-first approach - flexible
it 'sends notification' do
  notifier = double('notifier').as_null_object
  allow(notifier).to receive(:send)
  
  service = Service.new(notifier)
  service.process
  
  expect(notifier).to have_received(:send) do |args|
    expect(args[:type]).to eq('alert')
    expect(args[:message]).to include('completed')
  end
end

Humble Object Pattern minimizes untestable code by extracting testable logic into separate classes while leaving only integration code in hard-to-test objects.

# Hard to test - mixed concerns
class EmailController
  def send_confirmation(user)
    # Complex formatting logic mixed with email sending
    subject = "Welcome #{user.name}!"
    body = <<~EMAIL
      Hello #{user.name},
      Your account has been created.
      Login at: #{generate_login_url(user)}
    EMAIL
    
    Mailer.send(to: user.email, subject: subject, body: body)
  end
end

# Easier to test - extracted logic
class ConfirmationEmail
  def initialize(user)
    @user = user
  end
  
  def subject
    "Welcome #{@user.name}!"
  end
  
  def body
    # Testable without mailer
  end
end

class EmailController
  def send_confirmation(user)
    email = ConfirmationEmail.new(user)
    Mailer.send(
      to: user.email,
      subject: email.subject,
      body: email.body
    )
  end
end

Builder Pattern for Complex Doubles constructs doubles with many methods through fluent interface, improving readability for complex test setups.

class DoubleBuilder
  def initialize(name)
    @double = double(name)
    @stubs = {}
  end
  
  def responds_to(method, value)
    @stubs[method] = value
    self
  end
  
  def build
    @stubs.each do |method, value|
      allow(@double).to receive(method).and_return(value)
    end
    @double
  end
end

# Usage
user = DoubleBuilder.new('user')
  .responds_to(:name, 'Alice')
  .responds_to(:email, 'alice@example.com')
  .responds_to(:admin?, false)
  .build

Tools & Ecosystem

RSpec-Mocks serves as the de facto standard for test doubles in Ruby. Part of the RSpec testing framework, it provides comprehensive mocking, stubbing, and spying capabilities with expressive syntax.

# Gemfile
gem 'rspec', '~> 3.13'

# Key features
allow(object).to receive(:method)  # Stubbing
expect(object).to receive(:method)  # Mocking
expect(object).to have_received(:method)  # Spying
instance_double('ClassName')  # Verifying doubles

RSpec-Mocks integrates with RSpec's expectation framework and provides verifying doubles that ensure stubbed methods actually exist on real classes, catching API mismatches between tests and production code.

Minitest::Mock ships with Ruby's standard library through Minitest. It provides simpler, more explicit mocking compared to RSpec, suitable for developers who prefer minimal dependencies.

require 'minitest/autorun'
require 'minitest/mock'

mock = Minitest::Mock.new
mock.expect(:method_name, return_value, [expected_args])
# Use mock
mock.verify  # Raises if expectations not met

Minitest mocks require explicit verification and don't automatically restore stubbed methods, placing more responsibility on test authors.

WebMock specializes in stubbing HTTP requests, essential for testing code that makes web API calls without hitting actual endpoints.

# Gemfile
gem 'webmock'

# Stub HTTP requests
stub_request(:get, 'https://api.example.com/users/1')
  .to_return(status: 200, body: '{"name": "Alice"}')

# Test makes actual HTTP call, WebMock intercepts
response = HTTParty.get('https://api.example.com/users/1')
# Returns stubbed response

WebMock prevents any HTTP requests from leaving the test process, catching accidentally un-stubbed API calls. It supports request matching by URL, headers, and body content.

VCR records real HTTP interactions and replays them in tests, bridging the gap between mocking and integration testing. First test run hits real APIs and records responses; subsequent runs replay recorded data.

# Gemfile
gem 'vcr'

VCR.configure do |config|
  config.cassette_library_dir = 'spec/cassettes'
  config.hook_into :webmock
end

# Test with VCR
VCR.use_cassette('github_api') do
  response = GitHub.get_user('octocat')
  # First run: makes real request, records response
  # Later runs: replays recorded response
end

VCR works well for testing external API integration with real response structures while maintaining test speed and reliability.

Timecop stubs time-dependent code, controlling Time.now and Date.today to test time-sensitive logic without waiting.

# Gemfile
gem 'timecop'

# Travel to specific time
Timecop.freeze(Time.local(2025, 1, 1, 12, 0, 0)) do
  # Code here sees frozen time
  Time.now  # => 2025-01-01 12:00:00
end

# Travel forward
Timecop.travel(1.day.from_now) do
  # Test expiration logic
end

Bogus provides contract testing for doubles, verifying that stubbed methods match real class interfaces through runtime inspection.

# Gemfile
gem 'bogus'

# Creates fake that must match real class
user = fake(:user)
user.stubs(:name).returns('Alice')  # Fails if User#name doesn't exist

TestProf includes features for analyzing mock usage and identifying slow doubles, helping optimize test performance.

# Gemfile
gem 'test-prof'

# Analyze which doubles slow down tests
TEST_PROF_MOCKS=1 rspec
# Reports mocking statistics

RSpec Verifying Doubles deserve special mention as a critical feature preventing test double drift from production code.

# Standard double - no verification
user = double('user', name: 'Alice', admin?: true, deleted?: false)

# Verifying double - ensures methods exist
user = instance_double('User', name: 'Alice', admin?: true)
# Raises error if User class lacks 'name' or 'admin?' methods

# Class double for class methods
User = class_double('User').as_stubbed_const
allow(User).to receive(:find).and_return(user_instance)

Common Pitfalls

Over-Mocking creates brittle tests coupled to implementation details rather than behavior. Tests that mock every collaborator become maintenance nightmares, failing whenever internal code structure changes.

# Over-mocked - brittle
it 'processes order' do
  validator = double('validator')
  expect(validator).to receive(:validate).and_return(true)
  
  calculator = double('calculator')
  expect(calculator).to receive(:calculate_tax).and_return(10)
  expect(calculator).to receive(:calculate_shipping).and_return(5)
  
  inventory = double('inventory')
  expect(inventory).to receive(:reserve).and_return(true)
  
  # Test coupled to exact sequence of internal calls
  processor.process(order, validator, calculator, inventory)
end

# Better - test outcomes
it 'processes order' do
  processor.process(order)
  expect(order.status).to eq('processed')
  expect(order.total).to eq(115)
end

Testing Implementation Instead of Behavior verifies internal method calls rather than external observable effects, creating tests that pass despite broken functionality.

# Testing implementation
it 'caches user data' do
  cache = double('cache')
  expect(cache).to receive(:set).with('user_1', anything)
  service = UserService.new(cache)
  service.get_user(1)
end

# Testing behavior
it 'returns user data quickly on repeated calls' do
  service = UserService.new
  first_call = Benchmark.realtime { service.get_user(1) }
  second_call = Benchmark.realtime { service.get_user(1) }
  expect(second_call).to be < first_call / 10
end

Incomplete Mock Verification fails to verify all expected interactions, allowing bugs where methods aren't called when they should be.

# Incomplete - doesn't verify notification sent
it 'processes payment' do
  gateway = double('gateway')
  allow(gateway).to receive(:charge).and_return(success: true)
  
  processor.process_payment(payment)
  # Missing: verify notification service called
end

# Complete verification
it 'processes payment and notifies customer' do
  gateway = double('gateway')
  allow(gateway).to receive(:charge).and_return(success: true)
  
  notifier = double('notifier')
  expect(notifier).to receive(:send).with(hash_including(type: 'payment_success'))
  
  processor.process_payment(payment, notifier: notifier)
end

Stubbing Methods That Don't Exist creates false confidence when tests pass but production code fails because stubbed methods don't match real interfaces.

# Dangerous - method doesn't exist on real User
user = double('user')
allow(user).to receive(:full_name).and_return('Alice Smith')
# Production User class has 'name', not 'full_name'

# Safe - verifying double catches mismatch
user = instance_double('User')
allow(user).to receive(:full_name).and_return('Alice Smith')
# Raises error: User does not implement full_name

Mock Leakage Between Tests occurs when mocks persist beyond test boundaries, causing mysterious failures in unrelated tests.

# Leaky mock
before(:all) do
  allow(ExternalAPI).to receive(:fetch).and_return('stubbed')
end
# Stub persists for all tests in suite

# Proper scoping
before(:each) do
  allow(ExternalAPI).to receive(:fetch).and_return('stubbed')
end
# Stub cleaned up after each test

Circular Mocking creates test doubles that reference each other, producing confusing dependency chains that hide the actual code flow.

# Circular confusion
user_mock = double('user')
order_mock = double('order')
allow(user_mock).to receive(:orders).and_return([order_mock])
allow(order_mock).to receive(:user).and_return(user_mock)
# Which object is being tested?

Mocking Time Without Cleanup leaves time frozen or manipulated after tests complete, causing failures in subsequent tests that depend on real time.

# Dangerous - time stays frozen
Timecop.freeze(Time.local(2025, 1, 1))
# Test code
# Time never unfreezes

# Safe - automatic cleanup
it 'processes daily batch' do
  Timecop.freeze(Time.local(2025, 1, 1)) do
    # Test code
  end
  # Time automatically restored
end

Asserting on Mocks Instead of Real Objects verifies mock behavior rather than actual code results, testing the test framework instead of production code.

# Wrong - testing the mock
it 'saves user' do
  mock_user = double('user')
  allow(mock_user).to receive(:save).and_return(true)
  expect(mock_user.save).to be true  # Just testing mock returns true
end

# Right - testing actual behavior
it 'persists user to database' do
  user = User.new(name: 'Alice')
  user.save
  expect(User.find_by(name: 'Alice')).to eq(user)
end

Over-Specifying Return Values makes tests fragile by requiring exact return values when any valid response would work.

# Over-specified
allow(service).to receive(:fetch_data).and_return({
  id: 1, name: 'Test', email: 'test@example.com', 
  created_at: Time.parse('2025-01-01'), 
  updated_at: Time.parse('2025-01-01'),
  metadata: { source: 'api', version: 2 }
})

# Appropriately specified
allow(service).to receive(:fetch_data).and_return(
  double(id: 1, name: 'Test')
)

Reference

Test Double Type Comparison

Type Purpose Verification When to Use
Stub Provides canned responses None - state verification only Need to control return values without verifying calls
Mock Enforces expected interactions Automatic - fails if not called correctly Must verify specific methods receive expected calls
Spy Records all interactions Manual - check after execution Want to inspect what happened without strict expectations
Fake Working simplified implementation Through actual usage Need realistic behavior without external dependencies
Dummy Placeholder for unused parameters None Parameter required but not used in test scenario

RSpec Double Methods

Method Purpose Example
double Create pure test double double('name')
instance_double Create verifying instance double instance_double('User')
class_double Create verifying class double class_double('User')
object_double Create double from real object object_double(User.new)
spy Create spy double spy('name')
allow Configure stub behavior allow(obj).to receive(:method)
expect Set mock expectation expect(obj).to receive(:method)
have_received Verify spy interactions expect(obj).to have_received(:method)
and_return Specify return value receive(:method).and_return(value)
and_raise Specify exception receive(:method).and_raise(Error)
with Match arguments receive(:method).with(arg1, arg2)
exactly Specify call count receive(:method).exactly(3).times
once Expect single call receive(:method).once
twice Expect two calls receive(:method).twice
at_least Minimum call count receive(:method).at_least(2).times
at_most Maximum call count receive(:method).at_most(5).times

Minitest Mock Methods

Method Purpose Example
Minitest::Mock.new Create mock object mock = Minitest::Mock.new
expect Add expectation mock.expect(:method, return_val, args)
verify Check all expectations met mock.verify
stub Temporary method replacement obj.stub(:method, value) { code }

Common Argument Matchers

Matcher Matches Example
anything Any argument with(anything)
any_args Any number of arguments with(any_args)
no_args No arguments with(no_args)
kind_of(Class) Instance of class with(kind_of(String))
instance_of(Class) Exact class match with(instance_of(User))
hash_including Hash containing keys with(hash_including(id: 1))
hash_excluding Hash without keys with(hash_excluding(:password))
array_including Array containing values with(array_including(1, 2))
duck_type Object responding to methods with(duck_type(:each, :map))
satisfy Custom matcher block with(satisfy { value > 0 })
Regexp Pattern match with(/test@.*/)

Test Double Decision Matrix

Scenario Recommended Approach Rationale
External API call Stub with WebMock Control responses, prevent network calls
Database query Use test database or stub repository Real queries validate SQL, stubs test logic
Email sending Mock mailer Verify email sent with correct data
Time-dependent code Stub Time.now with Timecop Control time without waiting
Complex collaborator Real object with dependency injection Maintain integration, replace external deps
Payment processing Stub gateway Avoid actual charges, control responses
File operations Fake in-memory filesystem Fast, isolated, repeatable
Callback verification Mock callback Ensure callbacks invoked correctly
Retry logic Stub with progressive returns Simulate failure then success
Logging Spy on logger Verify messages without output noise

Mock Verification Patterns

Pattern Code Use When
Called once expect(obj).to receive(:method).once Method must be called exactly once
Not called expect(obj).not_to receive(:method) Method must not be called
Called with args expect(obj).to receive(:method).with(arg) Arguments must match exactly
Called in order expect(obj).to receive(:first).ordered; expect(obj).to receive(:second).ordered Call sequence matters
Multiple calls expect(obj).to receive(:method).twice Specific call count required
At least N times expect(obj).to receive(:method).at_least(3).times Minimum calls required
With block argument expect(obj).to receive(:method) { verify block } Need to verify yielded values
Return different values allow(obj).to receive(:method).and_return(1, 2, 3) Progressive state changes

Stub Configuration Patterns

Pattern Code Use When
Simple return allow(obj).to receive(:method).and_return(value) Fixed return value
Raise exception allow(obj).to receive(:method).and_raise(Error) Test error handling
Call original allow(obj).to receive(:method).and_call_original Partial stubbing
Yield values allow(obj).to receive(:method).and_yield(values) Method accepts block
Return multiple allow(obj).to receive(:method).and_return(1, 2, 3) Different values per call
Conditional return allow(obj).to receive(:method) { conditional_logic } Dynamic behavior
Null object double.as_null_object Respond to anything
Partial double allow(real_obj).to receive(:method) Mix real and stubbed