CrackedRuby logo

CrackedRuby

RSpec

Complete guide to RSpec test doubles including mocks, stubs, spies, and verification patterns for effective test isolation.

Testing and Quality Testing Frameworks
8.1.1

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