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 |