Overview
RSpec test doubles replace real objects during testing to control behavior and verify interactions. Ruby implements test doubles through the rspec-mocks
gem, providing three primary types: stubs that return predetermined values, mocks that verify method calls, and spies that record interactions for later verification.
Test doubles isolate code under test from dependencies, speed up test execution, and enable testing of error conditions or edge cases difficult to reproduce with real objects. RSpec creates doubles using double()
, instance_double()
, and class_double()
methods, each serving different verification needs.
# Basic double creation
user_double = double("User")
allow(user_double).to receive(:name).and_return("John")
# Verifying double
payment_service = double("PaymentService")
expect(payment_service).to receive(:charge).with(100)
RSpec doubles integrate with the test framework's lifecycle, automatically cleaning up expectations and stubs after each example. The library provides extensive configuration options for method stubbing, argument matching, and return value specification.
# Instance double with type checking
user = instance_double("User", name: "Alice", age: 30)
expect(user.name).to eq("Alice")
Basic Usage
Create test doubles using the double()
method with an optional name for debugging purposes. The name appears in error messages but doesn't affect functionality. Stub methods using allow().to receive()
syntax to define return values or behavior.
describe PaymentProcessor do
let(:gateway) { double("PaymentGateway") }
before do
allow(gateway).to receive(:charge).and_return(true)
allow(gateway).to receive(:refund).and_return(false)
end
it "processes successful payments" do
processor = PaymentProcessor.new(gateway)
result = processor.charge_customer(100)
expect(result).to be_truthy
end
end
Set expectations on doubles using expect().to receive()
to verify that specific methods get called with expected arguments. Expectations must be set before the code executes, unlike stubs which can be defined in setup blocks.
describe EmailService do
it "sends welcome email to new users" do
mailer = double("Mailer")
expect(mailer).to receive(:deliver).with("welcome", "user@example.com")
service = EmailService.new(mailer)
service.welcome_new_user("user@example.com")
end
end
Stub multiple methods on the same double using hash syntax for concise setup. Chain method calls using and_return()
, and_raise()
, or and_yield()
to define complex behaviors.
# Multiple method stubs
api_client = double("ApiClient", {
get: { status: 200, body: '{"data": "success"}' },
post: { status: 201, body: '{"id": 123}' },
authenticated?: true
})
# Chained behaviors
allow(file_handler).to receive(:read)
.and_return("line 1\nline 2")
.and_raise(IOError, "File not found")
Use instance_double()
when you want type checking against real class interfaces. This prevents stubbing methods that don't exist on the actual class, catching interface changes during refactoring.
describe UserService do
let(:user) { instance_double("User") }
before do
# This will fail if User class doesn't have #save method
allow(user).to receive(:save).and_return(true)
allow(user).to receive(:valid?).and_return(true)
end
end
Advanced Usage
Configure complex return values using blocks with and_return()
to simulate stateful behavior or conditional responses. Blocks receive the arguments passed to the stubbed method, enabling dynamic behavior based on input parameters.
describe CacheService do
it "handles cache misses and hits" do
cache = double("Cache")
stored_data = {}
allow(cache).to receive(:get) do |key|
stored_data[key]
end
allow(cache).to receive(:set) do |key, value|
stored_data[key] = value
true
end
service = CacheService.new(cache)
expect(service.fetch("user:1")).to be_nil
service.store("user:1", { name: "Alice" })
expect(service.fetch("user:1")).to eq({ name: "Alice" })
end
end
Create partial doubles using allow().to receive_messages()
to stub multiple methods with different return values in a single call. This approach works well for configuration objects or APIs with many related methods.
# Partial stubbing with multiple methods
config = double("Configuration")
allow(config).to receive_messages({
database_url: "postgresql://localhost/test",
redis_url: "redis://localhost:6379/0",
api_timeout: 30,
retry_attempts: 3
})
Implement method call counting and verification using exactly()
, at_least()
, and at_most()
matchers. These matchers verify interaction patterns beyond simple presence or absence of method calls.
describe BatchProcessor do
it "retries failed operations with exponential backoff" do
external_service = double("ExternalService")
allow(external_service).to receive(:process)
.and_raise(TimeoutError)
.exactly(3).times
processor = BatchProcessor.new(external_service, max_retries: 3)
expect { processor.run }.to raise_error(TimeoutError)
end
end
Use class_double()
for stubbing class methods and constants while maintaining type safety. Class doubles verify that stubbed class methods actually exist on the target class.
describe ReportGenerator do
it "generates reports using current timestamp" do
time_class = class_double("Time")
frozen_time = Time.parse("2023-06-15 10:30:00")
allow(time_class).to receive(:now).and_return(frozen_time)
allow(Time).to receive(:now).and_return(frozen_time)
generator = ReportGenerator.new
report = generator.create_daily_report
expect(report.timestamp).to eq(frozen_time)
end
end
Chain expectations with different return values for consecutive calls using multiple and_return()
calls or arrays. This pattern simulates changing state or progressive API responses.
# Progressive return values
api_client = double("ApiClient")
allow(api_client).to receive(:fetch_status)
.and_return("pending", "processing", "completed")
# Or using array syntax
allow(api_client).to receive(:poll)
.and_return(*["waiting", "running", "finished"])
Error Handling & Debugging
Handle verification failures by examining RSpec's detailed error messages that show expected versus actual method calls. Failed expectations display the stubbed object name, method signature, and argument comparison to identify mismatches.
describe AuthenticationService do
it "demonstrates detailed error reporting" do
authenticator = double("Authenticator")
expect(authenticator).to receive(:verify)
.with("correct_token", hash_including(scope: "admin"))
service = AuthenticationService.new(authenticator)
# This will fail with detailed message about argument mismatch
service.authenticate("wrong_token", scope: "user")
end
end
Debug double interactions using spy()
doubles that record all method calls without requiring upfront expectations. Spies enable after-the-fact verification using have_received()
matcher, helpful when call order or timing is unpredictable.
describe EventLogger do
it "logs multiple events in correct order" do
logger_spy = spy("Logger")
event_processor = EventLogger.new(logger_spy)
event_processor.handle_user_login("user123")
event_processor.handle_page_view("/dashboard")
expect(logger_spy).to have_received(:info)
.with("User logged in: user123").ordered
expect(logger_spy).to have_received(:info)
.with("Page viewed: /dashboard").ordered
end
end
Handle unexpected method calls by setting default responses using as_null_object()
. Null object doubles return themselves for any undefined method, preventing NoMethodError exceptions during exploratory testing.
describe ExperimentalFeature do
it "works with incomplete API specification" do
flexible_api = double("FlexibleApi").as_null_object
# Won't raise errors for undefined methods
feature = ExperimentalFeature.new(flexible_api)
result = feature.try_new_functionality
expect(result).not_to be_nil
end
end
Validate argument patterns using RSpec's argument matchers like anything()
, kind_of()
, instance_of()
, and hash_including()
. Complex matchers enable flexible verification while maintaining test intent clarity.
describe DataValidator do
it "validates complex data structures" do
database = double("Database")
expect(database).to receive(:save).with(
hash_including(
id: kind_of(Integer),
email: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i,
metadata: hash_including(created_at: kind_of(Time))
)
)
validator = DataValidator.new(database)
validator.process_user_data({
id: 123,
email: "test@example.com",
metadata: { created_at: Time.now, source: "api" }
})
end
end
Testing Strategies
Test edge cases using and_raise()
to simulate error conditions that are difficult to reproduce with real objects. Raise specific exceptions with custom messages to verify error handling pathways.
describe FileProcessor do
it "handles file system errors gracefully" do
file_system = double("FileSystem")
allow(file_system).to receive(:read)
.and_raise(Errno::ENOENT, "No such file or directory")
processor = FileProcessor.new(file_system)
expect { processor.load_config }.not_to raise_error
expect(processor.config).to eq(FileProcessor::DEFAULT_CONFIG)
end
end
Test callback and block behavior using and_yield()
to simulate methods that accept blocks. Control the arguments passed to blocks to test different execution paths.
describe DatabaseTransaction do
it "rolls back on exceptions within transaction block" do
connection = double("Connection")
expect(connection).to receive(:begin_transaction)
expect(connection).to receive(:rollback)
expect(connection).not_to receive(:commit)
allow(connection).to receive(:transaction).and_yield
transaction = DatabaseTransaction.new(connection)
expect {
transaction.execute { raise StandardError, "Processing failed" }
}.to raise_error(StandardError)
end
end
Verify method call sequences using ordered()
expectations to ensure operations occur in the correct sequence. Ordered expectations fail if methods are called out of order, regardless of whether all expected calls occur.
describe PaymentWorkflow do
it "processes payments in correct sequence" do
gateway = double("PaymentGateway")
expect(gateway).to receive(:validate_card).ordered
expect(gateway).to receive(:authorize_payment).ordered
expect(gateway).to receive(:capture_payment).ordered
expect(gateway).to receive(:send_receipt).ordered
workflow = PaymentWorkflow.new(gateway)
workflow.process_payment(card: "4111111111111111", amount: 100)
end
end
Test partial stubbing on real objects using allow()
with actual instances. This technique stubs specific methods while preserving the object's other behaviors, useful for isolating external dependencies.
describe UserRegistration do
it "sends email without actually sending" do
user = User.new(email: "test@example.com")
allow(user).to receive(:send_welcome_email).and_return(true)
registration = UserRegistration.new
result = registration.register(user)
expect(result).to be_successful
expect(user).to have_received(:send_welcome_email)
end
end
Common Pitfalls
Method call verification fails when argument matchers are too strict or don't account for object state changes. Arguments passed to expected methods might be modified between creation and method call, causing matcher failures.
# Problematic: object state changes after expectation
describe OrderProcessor do
it "demonstrates argument mutation issue" do
shipper = double("Shipper")
order = Order.new(items: ["book"])
# This expectation may fail if order gets modified
expect(shipper).to receive(:ship).with(order)
processor = OrderProcessor.new(shipper)
# OrderProcessor might modify order.items internally
processor.fulfill(order)
end
end
# Solution: use flexible matchers
describe OrderProcessor do
it "handles argument mutation correctly" do
shipper = double("Shipper")
expect(shipper).to receive(:ship)
.with(instance_of(Order))
processor = OrderProcessor.new(shipper)
order = Order.new(items: ["book"])
processor.fulfill(order)
end
end
Stub return values become stale when real object interfaces change but test doubles aren't updated. Instance doubles partially address this by verifying method existence, but return value types and structures can still diverge.
# Brittle: assumes User#preferences returns hash
describe UserDashboard do
it "shows user preferences" do
user = double("User")
allow(user).to receive(:preferences)
.and_return({ theme: "dark", language: "en" })
dashboard = UserDashboard.new(user)
expect(dashboard.theme).to eq("dark")
end
end
# Better: use instance_double with realistic data
describe UserDashboard do
it "shows user preferences with type safety" do
user = instance_double("User")
allow(user).to receive(:preferences)
.and_return(UserPreferences.new(theme: "dark", language: "en"))
dashboard = UserDashboard.new(user)
expect(dashboard.theme).to eq("dark")
end
end
Expectation order conflicts occur when combining ordered and unordered expectations on the same double. RSpec requires all expectations on a double to be either ordered or unordered, not mixed within the same example.
# Problematic: mixing ordered and unordered expectations
describe ServiceWorkflow do
it "demonstrates expectation order conflict" do
service = double("Service")
expect(service).to receive(:start).ordered
expect(service).to receive(:log) # Unordered - causes conflict
expect(service).to receive(:finish).ordered
end
end
# Solution: make all expectations ordered or use separate doubles
describe ServiceWorkflow do
it "uses consistent expectation ordering" do
service = double("Service")
logger = double("Logger")
expect(service).to receive(:start).ordered
expect(service).to receive(:finish).ordered
expect(logger).to receive(:log) # Separate double, no conflict
end
end
Stub method side effects get lost when using and_return()
exclusively. Real methods often modify object state or interact with external systems, requiring and_wrap_original()
or custom block implementations.
# Incomplete: ignores side effects
allow(user).to receive(:save).and_return(true)
# Better: preserve side effects while controlling outcome
allow(user).to receive(:save) do |*args|
user.instance_variable_set(:@persisted, true)
user.instance_variable_set(:@id, 123)
true
end
Verification timing errors happen when expectations are checked before the stubbed code executes. Asynchronous operations or delayed method calls can cause expectation failures even when the methods eventually get called.
# Problematic: async operation not complete when verified
describe AsyncProcessor do
it "processes jobs asynchronously" do
worker = double("Worker")
expect(worker).to receive(:perform)
processor = AsyncProcessor.new(worker)
processor.enqueue_job("task_data")
# Expectation checked immediately, job may still be queued
end
end
# Solution: use spies for async verification
describe AsyncProcessor do
it "processes jobs asynchronously with spy" do
worker = spy("Worker")
processor = AsyncProcessor.new(worker)
processor.enqueue_job("task_data")
processor.wait_for_completion
expect(worker).to have_received(:perform).with("task_data")
end
end
Reference
Double Creation Methods
Method | Parameters | Returns | Description |
---|---|---|---|
double(name = nil, stubs = {}) |
name (String), stubs (Hash) |
RSpec::Mocks::Double |
Creates basic test double with optional name and initial stubs |
instance_double(class_name, stubs = {}) |
class_name (String/Class), stubs (Hash) |
RSpec::Mocks::InstanceVerifyingDouble |
Creates double that verifies against real class instance methods |
class_double(class_name, stubs = {}) |
class_name (String/Class), stubs (Hash) |
RSpec::Mocks::ClassVerifyingDouble |
Creates double that verifies against real class methods |
object_double(object, stubs = {}) |
object (Object), stubs (Hash) |
RSpec::Mocks::ObjectVerifyingDouble |
Creates double that verifies against specific object instance |
spy(name = nil, stubs = {}) |
name (String), stubs (Hash) |
RSpec::Mocks::Double |
Creates double that records all method calls for later verification |
Stubbing Methods
Method | Parameters | Returns | Description |
---|---|---|---|
allow(double).to receive(method) |
method (Symbol/String) |
RSpec::Mocks::MessageExpectation |
Stubs method without verification requirements |
allow(double).to receive_messages(hash) |
hash (Hash) |
Array<RSpec::Mocks::MessageExpectation> |
Stubs multiple methods with return values |
allow(double).to receive_message_chain(methods) |
methods (String) |
RSpec::Mocks::MessageExpectation |
Stubs chained method calls |
allow_any_instance_of(class).to receive(method) |
class (Class), method (Symbol) |
RSpec::Mocks::MessageExpectation |
Stubs method on any instance of class |
Expectation Methods
Method | Parameters | Returns | Description |
---|---|---|---|
expect(double).to receive(method) |
method (Symbol/String) |
RSpec::Mocks::MessageExpectation |
Sets expectation that method must be called |
expect(double).not_to receive(method) |
method (Symbol/String) |
RSpec::Mocks::MessageExpectation |
Sets expectation that method must not be called |
expect(double).to have_received(method) |
method (Symbol/String) |
RSpec::Mocks::MessageExpectation |
Verifies method was called on spy double |
Response Configuration
Method | Parameters | Returns | Description |
---|---|---|---|
and_return(*values) |
values (Array) |
RSpec::Mocks::MessageExpectation |
Specifies return values for consecutive calls |
and_raise(exception) |
exception (Class/Instance/String) |
RSpec::Mocks::MessageExpectation |
Raises exception when method called |
and_yield(*args) |
args (Array) |
RSpec::Mocks::MessageExpectation |
Yields arguments to block passed to method |
and_call_original() |
None | RSpec::Mocks::MessageExpectation |
Calls original method implementation |
and_wrap_original(&block) |
block (Proc) |
RSpec::Mocks::MessageExpectation |
Wraps original method with custom behavior |
Argument Matchers
Matcher | Usage | Description |
---|---|---|
anything() |
expect(obj).to receive(:method).with(anything) |
Matches any single argument |
any_args() |
expect(obj).to receive(:method).with(any_args) |
Matches any number of arguments |
no_args() |
expect(obj).to receive(:method).with(no_args) |
Matches no arguments |
kind_of(class) |
with(kind_of(String)) |
Matches argument of specified type or subtype |
instance_of(class) |
with(instance_of(Array)) |
Matches argument of exact type |
hash_including(hash) |
with(hash_including(key: value)) |
Matches hash containing specified key-value pairs |
array_including(array) |
with(array_including([1, 2])) |
Matches array containing specified elements |
a_string_matching(pattern) |
with(a_string_matching(/pattern/)) |
Matches string against regex pattern |
Call Count Verification
Method | Usage | Description |
---|---|---|
once() |
expect(obj).to receive(:method).once |
Expects exactly one method call |
twice() |
expect(obj).to receive(:method).twice |
Expects exactly two method calls |
exactly(n).times |
exactly(3).times |
Expects exactly n method calls |
at_least(n).times |
at_least(2).times |
Expects minimum n method calls |
at_most(n).times |
at_most(5).times |
Expects maximum n method calls |
Configuration Options
Setting | Default | Description |
---|---|---|
verify_doubled_constant_names |
false |
Validates that doubled class names refer to real constants |
verify_partial_doubles |
false |
Enables verification for partially stubbed objects |
allow_message_expectations_on_nil |
false |
Permits expectations on nil objects |
color |
true |
Enables colored output in failure messages |
Common Error Types
Error | Cause | Solution |
---|---|---|
MockExpectationError |
Expected method call didn't occur | Verify code path calls expected method |
ArgumentError |
Wrong arguments passed to expected method | Check argument matchers and actual values |
NoMethodError |
Stubbed method doesn't exist on real class | Use instance_double or verify method names |
UnexpectedMessageError |
Method called but not expected | Add expectation or use allow instead |