CrackedRuby logo

CrackedRuby

Mocking and Stubbing

Guide covering test doubles, method stubs, and mock objects in Ruby testing frameworks.

Testing and Quality Test Patterns
8.2.3

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