Overview
Mocking and stubbing replace real objects and methods with controlled test doubles during testing. Ruby provides multiple approaches through built-in libraries and testing frameworks. The test-unit
library includes basic stubbing capabilities, while RSpec offers comprehensive mocking and stubbing features through rspec-mocks
. Minitest provides stubbing through Minitest::Mock
and method-level stubbing.
Test doubles serve three primary purposes: isolating units under test, controlling external dependencies, and verifying interactions between objects. Stubs return predetermined values when called, while mocks verify that specific methods are called with expected arguments. Ruby's dynamic nature makes creating and manipulating test doubles straightforward through method redefinition and singleton methods.
# Basic stub with RSpec
allow(user).to receive(:name).and_return("Alice")
# Basic mock verification
expect(mailer).to receive(:send_email).with("alice@example.com")
The distinction between stubs and mocks matters for test design. Stubs focus on state verification by controlling return values, while mocks focus on behavior verification by asserting method calls occurred. Ruby testing frameworks provide both approaches, often within the same test double object.
Basic Usage
Creating Test Doubles
RSpec provides double
for creating standalone test objects and instance_double
for type-checked doubles based on existing classes.
# Generic test double
user_double = double("User")
allow(user_double).to receive(:email).and_return("test@example.com")
# Type-checked double
user_double = instance_double(User, email: "test@example.com", name: "John")
Minitest uses Minitest::Mock
for creating test doubles with predefined method expectations.
mock = Minitest::Mock.new
mock.expect(:call, "result", ["arg1", "arg2"])
mock.expect(:status, :active)
# Use the mock
mock.call("arg1", "arg2") # => "result"
mock.verify # Raises error if expectations not met
Method Stubbing
Stubbing replaces method implementations temporarily during test execution. RSpec's allow
syntax provides flexible stubbing options.
class UserService
def fetch_user(id)
api_client.get("/users/#{id}")
end
end
# Stub external API call
service = UserService.new
allow(service).to receive(:api_client).and_return(mock_client)
allow(mock_client).to receive(:get).and_return({ id: 1, name: "Alice" })
Minitest provides method-level stubbing that automatically restores original methods after test completion.
def test_user_creation
Time.stub(:now, Time.new(2023, 1, 1)) do
user = User.create(name: "Bob")
assert_equal Time.new(2023, 1, 1), user.created_at
end
end
Stub Return Values
Different return value patterns control test behavior. RSpec supports static values, computed values, and sequential returns.
# Static return value
allow(calculator).to receive(:add).and_return(42)
# Block-computed return value
allow(calculator).to receive(:add) { |a, b| a + b + 1 }
# Sequential returns
allow(api).to receive(:status)
.and_return(:pending, :processing, :completed)
# Conditional returns based on arguments
allow(repository).to receive(:find) do |id|
case id
when 1 then User.new(name: "Alice")
when 2 then User.new(name: "Bob")
else nil
end
end
Testing Strategies
State vs Behavior Verification
State verification tests the outcome of operations, while behavior verification tests the interactions that occur. Choose the appropriate strategy based on what the test should verify.
class OrderProcessor
def process(order, payment_gateway, notification_service)
result = payment_gateway.charge(order.amount, order.payment_method)
if result.success?
order.status = :completed
notification_service.send_confirmation(order.customer_email)
true
else
order.status = :failed
false
end
end
end
# State verification - testing the outcome
def test_successful_order_processing
gateway = double("PaymentGateway")
notifier = double("NotificationService")
allow(gateway).to receive(:charge).and_return(double(success?: true))
allow(notifier).to receive(:send_confirmation)
order = Order.new(amount: 100, customer_email: "test@example.com")
processor = OrderProcessor.new
result = processor.process(order, gateway, notifier)
assert_equal :completed, order.status
assert result
end
# Behavior verification - testing the interactions
def test_notification_sent_on_success
gateway = double("PaymentGateway")
notifier = double("NotificationService")
allow(gateway).to receive(:charge).and_return(double(success?: true))
order = Order.new(customer_email: "customer@example.com")
processor = OrderProcessor.new
expect(notifier).to receive(:send_confirmation).with("customer@example.com")
processor.process(order, gateway, notifier)
end
Partial Stubbing Patterns
Partial stubbing replaces specific methods on real objects while preserving other behavior. This approach works well for testing complex objects with multiple responsibilities.
class ReportGenerator
def generate_monthly_report(month)
data = fetch_data_for_month(month)
template = load_template("monthly")
render_report(template, data)
end
private
def fetch_data_for_month(month)
# Complex database query
end
end
def test_report_generation
generator = ReportGenerator.new
# Stub only the data fetching, test the rest
sample_data = [{ sales: 1000, expenses: 500 }]
allow(generator).to receive(:fetch_data_for_month).and_return(sample_data)
result = generator.generate_monthly_report("2023-01")
assert_includes result, "Sales: $1,000"
end
Mock Object Lifecycle
Proper mock lifecycle management prevents test pollution and ensures reliable test execution. RSpec automatically resets mocks between tests, but manual cleanup may be necessary for global stubs.
# RSpec - automatic cleanup
RSpec.describe Calculator do
it "adds numbers correctly" do
allow(Math).to receive(:sqrt).and_return(5.0)
# Math.sqrt automatically restored after test
end
end
# Minitest - manual verification required
def test_with_minitest_mock
mock = Minitest::Mock.new
mock.expect(:process, "result", [Hash])
service = DataService.new(mock)
service.process_data({})
mock.verify # Must call verify to check expectations
end
# Global stub restoration
def test_with_global_stub
original_method = Time.method(:now)
Time.define_singleton_method(:now) { Time.new(2023, 1, 1) }
# Test code here
Time.define_singleton_method(:now, original_method)
end
Advanced Usage
Argument Matchers and Constraints
Sophisticated argument matching provides precise control over when stubs respond and mocks verify calls. RSpec includes numerous built-in matchers for different data types and patterns.
# Hash matching with specific keys
allow(api).to receive(:create_user)
.with(hash_including(email: /\w+@\w+\.\w+/, age: be > 18))
.and_return(double(id: 123))
# Array matching with flexible content
expect(logger).to receive(:log)
.with(array_including("ERROR", match(/database/)))
# Block-based custom matching
allow(validator).to receive(:valid?) do |data|
data.is_a?(Hash) && data.key?(:required_field)
end
# Multiple argument patterns for same method
allow(cache).to receive(:get)
.with("user:1").and_return(user_data)
.with("user:2").and_return(other_user_data)
.with(anything).and_return(nil)
Spy Objects and Message Verification
Spy objects record method calls for later verification, enabling a test-after pattern where assertions happen after code execution.
class EmailService
def send_welcome_emails(users)
users.each do |user|
send_email(user.email, welcome_template, user: user)
end
end
end
def test_email_sending_with_spy
service = EmailService.new
email_spy = spy("EmailSender")
allow(service).to receive(:send_email).and_wrap_original do |original, *args|
email_spy.send_email(*args)
end
users = [User.new(email: "a@test.com"), User.new(email: "b@test.com")]
service.send_welcome_emails(users)
# Verify calls after execution
expect(email_spy).to have_received(:send_email).twice
expect(email_spy).to have_received(:send_email)
.with("a@test.com", anything, hash_including(user: users[0]))
end
Stub Chaining and Method Chains
Method chaining stubs handle fluent interfaces and builder patterns. RSpec supports chaining through receive_message_chain
and explicit stub chains.
# API client with fluent interface
class ApiClient
def users
UserEndpoint.new(self)
end
end
class UserEndpoint
def where(conditions)
# Returns filtered collection
end
def limit(count)
# Returns limited collection
end
end
# Stub the entire chain
allow(api_client).to receive_message_chain(:users, :where, :limit)
.and_return([user1, user2])
# More explicit chaining with intermediate objects
user_endpoint = double("UserEndpoint")
filtered_users = double("FilteredUsers")
allow(api_client).to receive(:users).and_return(user_endpoint)
allow(user_endpoint).to receive(:where).and_return(filtered_users)
allow(filtered_users).to receive(:limit).and_return([user1, user2])
Dynamic Stub Creation
Ruby's metaprogramming capabilities enable dynamic stub creation based on runtime conditions or configuration data.
def create_service_doubles(service_configs)
service_configs.map do |config|
service_double = double(config[:name])
config[:methods].each do |method_name, return_value|
allow(service_double).to receive(method_name).and_return(return_value)
end
service_double
end
end
# Usage in test
configs = [
{ name: "UserService", methods: { find: user, create: new_user } },
{ name: "EmailService", methods: { send: true, validate: true } }
]
services = create_service_doubles(configs)
Common Pitfalls
Over-Mocking and Brittle Tests
Excessive mocking creates tests that break when implementation details change, even when public behavior remains correct. Mock only the boundaries of the system under test.
# Brittle - mocks internal implementation
def test_user_creation_brittle
validator = double("Validator")
sanitizer = double("Sanitizer")
database = double("Database")
expect(validator).to receive(:validate_email).with("test@example.com")
expect(sanitizer).to receive(:sanitize_name).with("John Doe")
expect(database).to receive(:insert).with(anything)
UserService.new(validator, sanitizer, database).create_user({
email: "test@example.com",
name: "John Doe"
})
end
# Better - test the boundary
def test_user_creation_robust
database = double("Database")
expect(database).to receive(:insert)
.with(hash_including(email: "test@example.com", name: "John Doe"))
UserService.new(database).create_user({
email: "test@example.com",
name: "John Doe"
})
end
Stub Leakage Between Tests
Improperly scoped stubs affect subsequent tests, causing intermittent failures and test pollution. This occurs most commonly with global objects and class-level stubbing.
# Problematic - global stub without proper cleanup
def test_current_time_formatting
Time.stub(:now, Time.new(2023, 1, 1)) do
formatter = TimeFormatter.new
assert_equal "2023-01-01", formatter.current_date
end
# Time.now properly restored here
end
# Dangerous - global stub that persists
def test_dangerous_global_stub
allow(Time).to receive(:now).and_return(Time.new(2023, 1, 1))
# This stub persists to subsequent tests!
end
# Solution - explicit cleanup or scoped stubbing
def setup
@original_now = Time.method(:now)
end
def teardown
Time.define_singleton_method(:now, @original_now) if @original_now
end
Verification Order Dependencies
Mock verification fails when method calls occur in unexpected order, particularly with sequential return values and call counting.
# Order-dependent verification problem
def test_order_dependency_problem
service = double("Service")
expect(service).to receive(:step_one).ordered
expect(service).to receive(:step_two).ordered
# This will fail if order changes in implementation
processor.execute(service) # calls step_two then step_one
end
# Better - verify calls happened without order constraint
def test_calls_without_order
service = double("Service")
expect(service).to receive(:step_one)
expect(service).to receive(:step_two)
processor.execute(service)
end
# When order actually matters for correctness
def test_order_when_required
authenticator = double("Authenticator")
expect(authenticator).to receive(:validate_token).and_return(true).ordered
expect(authenticator).to receive(:refresh_session).ordered
# Order matters here for security
auth_service.authenticate_request(token, authenticator)
end
Stub Configuration Complexity
Complex stub configurations become difficult to maintain and understand. Extract common stub setups into helper methods and use factory patterns for reusable test doubles.
# Complex, hard to maintain
def test_complex_stub_configuration
payment_gateway = double("PaymentGateway")
allow(payment_gateway).to receive(:charge) do |amount, method, options|
if amount > 1000 && method == :credit_card && options[:cvv]
double(success?: true, transaction_id: "txn_123", fee: amount * 0.029)
elsif amount <= 1000 && method == :credit_card
double(success?: true, transaction_id: "txn_456", fee: amount * 0.025)
else
double(success?: false, error: "Invalid payment method")
end
end
end
# Cleaner - extracted helper
def create_payment_gateway_double
double("PaymentGateway").tap do |gateway|
allow(gateway).to receive(:charge, &method(:payment_gateway_response))
end
end
def payment_gateway_response(amount, method, options = {})
return failure_response("Invalid method") unless method == :credit_card
fee_rate = amount > 1000 ? 0.029 : 0.025
success_response("txn_#{rand(1000)}", amount * fee_rate)
end
def success_response(txn_id, fee)
double(success?: true, transaction_id: txn_id, fee: fee)
end
def failure_response(error)
double(success?: false, error: error)
end
Reference
RSpec Mocking Methods
Method | Parameters | Returns | Description |
---|---|---|---|
double(name) |
name (String), stubs (Hash) |
RSpec::Mocks::Double |
Creates anonymous test double |
instance_double(class) |
class (Class), stubs (Hash) |
RSpec::Mocks::InstanceVerifyingDouble |
Creates type-verified double |
class_double(class) |
class (Class), stubs (Hash) |
RSpec::Mocks::ClassVerifyingDouble |
Creates class-level double |
spy(name) |
name (String), stubs (Hash) |
RSpec::Mocks::Double |
Creates spy object for message verification |
Stub Configuration Methods
Method | Parameters | Returns | Description |
---|---|---|---|
allow(object).to receive(method) |
object (Object), method (Symbol) |
RSpec::Mocks::MessageExpectation |
Sets up method stub |
and_return(*values) |
values (Array) |
MessageExpectation |
Sets return values |
and_raise(exception) |
exception (Exception/String) |
MessageExpectation |
Raises exception when called |
and_yield(*args) |
args (Array) |
MessageExpectation |
Yields arguments to block |
and_call_original |
None | MessageExpectation |
Calls original method implementation |
and_wrap_original |
Block | MessageExpectation |
Wraps original method with custom logic |
Mock Verification Methods
Method | Parameters | Returns | Description |
---|---|---|---|
expect(object).to receive(method) |
object (Object), method (Symbol) |
MessageExpectation |
Sets expectation for method call |
have_received(method) |
method (Symbol) |
RSpec::Matchers::BuiltIn::HaveReceived |
Verifies spy received message |
with(*args) |
args (Array) |
MessageExpectation |
Specifies expected arguments |
exactly(n).times |
n (Integer) |
MessageExpectation |
Expects exact call count |
at_least(n).times |
n (Integer) |
MessageExpectation |
Expects minimum call count |
once |
None | MessageExpectation |
Expects exactly one call |
twice |
None | MessageExpectation |
Expects exactly two calls |
Minitest Mock Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Minitest::Mock.new |
None | Minitest::Mock |
Creates new mock object |
expect(method, return_value, args) |
method (Symbol), return_value (Object), args (Array) |
Mock |
Sets method expectation |
verify |
None | Boolean |
Verifies all expectations met |
Object.stub(method, value, &block) |
method (Symbol), value (Object), block (Proc) |
Object |
Temporarily stubs method |
Argument Matchers
Matcher | Usage | Description |
---|---|---|
anything |
with(anything) |
Matches any single argument |
any_args |
with(any_args) |
Matches any number of arguments |
no_args |
with(no_args) |
Matches no arguments |
hash_including(keys) |
with(hash_including(key: value)) |
Matches hash containing specified keys |
array_including(items) |
with(array_including("item")) |
Matches array containing specified items |
instance_of(class) |
with(instance_of(String)) |
Matches instance of specific class |
kind_of(class) |
with(kind_of(Numeric)) |
Matches object responding to is_a? |
match(pattern) |
with(match(/regex/)) |
Matches string against pattern |
be_within(delta).of(value) |
with(be_within(0.1).of(3.14)) |
Matches numeric value within range |
Error Types
Error | When Raised | Resolution |
---|---|---|
RSpec::Mocks::MockExpectationError |
Expected method not called | Verify code calls expected methods |
RSpec::Mocks::UnexpectedMessageError |
Unexpected method called on strict double | Add stub for method or use non-strict double |
RSpec::Mocks::ArgumentError |
Wrong arguments passed to stubbed method | Check argument matchers and actual call arguments |
Minitest::MockExpectationError |
Mock verification failed | Ensure all expected methods called correct number of times |
Configuration Options
Option | Values | Description |
---|---|---|
verify_partial_doubles |
true/false |
Verifies methods exist when stubbing real objects |
verify_doubled_constant_names |
true/false |
Verifies constant names when using stub_const |
allow_message_expectations_on_nil |
true/false |
Permits stubbing methods on nil objects |
when_calling_a_disabled_method |
:ignore/:warn/:raise |
Behavior when calling disabled stubbed method |