ruby

**Advanced Ruby Testing: 8 Essential Mock and Stub Patterns for Complex Scenarios**

Discover advanced Ruby mocking patterns for complex testing scenarios. Master sequence testing, fault injection, time control & event-driven systems.

**Advanced Ruby Testing: 8 Essential Mock and Stub Patterns for Complex Scenarios**

I want to talk about a part of testing in Ruby that often gets glossed over. We all know we should use mocks and stubs. We’re told they help us test in isolation. But when your code starts talking to payment gateways, sending emails, or coordinating complex workflows, basic stubbing just doesn’t cut it anymore. You need more control. You need precision. You need patterns.

Over time, I’ve collected a set of methods that help me test the tricky parts—the parts with sequences, time, failure, and events. These aren’t just tricks; they are ways to make your tests express exactly what you expect your code to do, especially when it interacts with the outside world. Let me walk you through them.

First, let’s consider order. Some operations must happen in a specific sequence. A payment might need authorization before capture, and a receipt must only be sent after success. Testing this with simple stubs is messy. You end up with tests full of state checks that don’t clearly communicate the required flow.

Instead, I use objects that expect a sequence of calls. Here’s how I think about it. I create a mock object that lets me declare, in a fluent and readable way, what calls I expect and in what order. The test then becomes a specification of that workflow.

class OrderFulfillmentTest
  def test_complete_fulfillment_flow
    inventory = MockInventoryService.new
    shipper = MockShippingService.new

    service = OrderFulfiller.new(inventory: inventory, shipper: shipper)

    # The story this test tells is clear:
    # 1. Reserve the items.
    # 2. Then create a shipment.
    # 3. Then mark the order as packed.
    inventory.expect(:reserve_items, true, with: [order_id: 'ord_123'])
             .expect(:deduct_stock, nil, with: ['ord_123'])

    shipper.expect(:create_label, 'label_456', with: ['ord_123'])
           .expect(:mark_as_packed, nil, with: ['ord_123'])

    result = service.fulfill_order('ord_123')

    # The verification is explicit.
    # It's not just that methods were called;
    # it's that they were called in this exact chain.
    inventory.verify!
    shipper.verify!

    assert result.completed?
  end
end

The power here is in the expect method. It returns the mock object itself, allowing me to chain the expectations. The verify! method at the end is crucial. It checks the recorded calls against the expected sequence. If deduct_stock was called before reserve_items, this test would fail. It catches errors in the process logic that a simple stub would miss.

The implementation for such a mock is more involved but straightforward.

class FluentMock
  def initialize
    @expectation_chain = []
    @call_log = []
  end

  def expect(method_name, return_value = nil, with: nil)
    # Add this step to the expected sequence.
    @expectation_chain << { method: method_name, returns: return_value, args: with }
    # Return self so we can chain.
    self
  end

  def method_missing(method_name, *args)
    # Record every call made to this object.
    @call_log << { method: method_name, args: args }

    # Find the next expectation in the chain for this method.
    # This is a simple implementation that expects calls in the exact order.
    expected = @expectation_chain.find { |exp| exp[:method] == method_name }
    if expected
      # Optional: you could add argument matching here.
      expected[:returns]
    else
      # Raise an error if an unexpected method is called.
      raise "Unexpected call: #{method_name}"
    end
  end

  def verify!
    # Check that the call log matches the expectation chain.
    @expectation_chain.each_with_index do |expected, index|
      actual_call = @call_log[index]
      unless actual_call && actual_call[:method] == expected[:method]
        raise "Expected #{expected[:method]} at step #{index}, but got #{actual_call&.dig(:method)}"
      end
      # You could also verify arguments here.
      if expected[:args] && actual_call[:args] != expected[:args]
        raise "Argument mismatch for #{expected[:method]}"
      end
    end
  end
end

This pattern gives me confidence that a multi-step process respects its required order. It turns a test into a readable script of interactions.

Sometimes, order is less important than the final outcome. You care that certain actions happened, but you don’t want to force a specific order in your test. This is where I use a spy. A spy is a passive recorder. You let the code run, and then you ask the spy what it saw.

I use this for auditing, logging, or notification systems. The key question is: “Was the correct thing recorded?”

class ComplianceServiceTest
  def test_sensitive_action_logs_appropriately
    audit_spy = AuditLogSpy.new

    service = ComplianceService.new(audit_logger: audit_spy)
    service.approve_transaction(transaction_id: 'tx_999', approver: 'user_42')

    # After the action, I interrogate the spy.
    # These queries are semantic and focused on the business requirement.
    assert audit_spy.entry_exists_for?(entity_id: 'tx_999', action: 'APPROVAL')
    assert_equal 'user_42', audit_spy.last_entry_for('tx_999')[:actor]
    refute audit_spy.contains_sensitive_data?('tx_999')
  end
end

The spy’s job is to remember and provide useful queries.

class AuditLogSpy
  def initialize
    @entries = []
  end

  def log(entity_id:, action:, details:, actor:)
    # It just stores the data. No complex logic.
    @entries << {
      entity_id: entity_id,
      action: action,
      details: details,
      actor: actor,
      timestamp: Time.now
    }
  end

  # The query methods are the valuable part.
  def entry_exists_for?(entity_id:, action:)
    @entries.any? { |e| e[:entity_id] == entity_id && e[:action] == action }
  end

  def last_entry_for(entity_id)
    @entries.select { |e| e[:entity_id] == entity_id }.last
  end

  def contains_sensitive_data?(entity_id)
    entry = last_entry_for(entity_id)
    # Simple, test-focused logic.
    entry && entry[:details].to_s.match?(/password|ssn|credit_card/)
  end
end

The spy doesn’t dictate behavior; it just observes and reports. This makes tests less brittle because they aren’t tied to the order of internal method calls, just the final recorded state.

Real business logic is rarely “call X, get Y.” It’s more like “call X with argument A, get Y; but with argument B, get Z.” My stubs need to be smart. I need conditional stubbing.

I achieve this by giving my mock objects blocks of logic instead of fixed return values. The block can inspect the arguments and decide what to return.

class TaxCalculatorTest
  def test_calculates_tax_by_region
    geo_service = SmartMock.new

    calculator = TaxCalculator.new(geo_service: geo_service)

    # The stub's behavior changes based on the input.
    geo_service.stub(:get_tax_rate) do |postal_code|
      case postal_code[0..4] # Check the first part of the code
      when '90210'
        0.095 # Beverly Hills rate
      when '10001'
        0.08875 # NYC rate
      else
        0.06 # Default rate
      end
    end

    assert_in_delta 9.50, calculator.calculate('90210', 100.0)
    assert_in_delta 8.88, calculator.calculate('10001', 100.0)
    assert_equal 6.00, calculator.calculate('12345', 100.0)
  end
end

The mock implementation is a simple registry of blocks.

class SmartMock
  def initialize
    @behavior = {}
  end

  def stub(method_name, &block)
    @behavior[method_name] = block
  end

  def method_missing(method_name, *args, &block)
    if @behavior.key?(method_name)
      # Execute the stored block with the given arguments.
      @behavior[method_name].call(*args, &block)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    @behavior.key?(method_name) || super
  end
end

This pattern is incredibly useful for testing code with business rules, feature flags, or any logic that branches based on external data.

Testing code that depends on time is a classic source of flaky tests. You can’t wait for real seconds or minutes to pass. You need to control the clock. Ruby’s Time class is a global, so we can stub its core methods like now to simulate any moment we want.

The trick is to be scrupulous about cleaning up after yourself so you don’t affect other tests. I use the pattern Time.stub(:now, frozen_time) which safely replaces the method for the duration of a block.

class SessionManagerTest
  def test_session_expires_after_timeout
    manager = SessionManager.new(timeout: 15.minutes)

    # Start at a known time.
    start_time = Time.new(2024, 5, 1, 12, 0, 0)
    Time.stub(:now, start_time) do
      session = manager.create_session(user_id: 55)
      assert manager.valid_session?(session.id)
    end

    # Simulate moving 14 minutes and 59 seconds forward.
    nearly_expired = start_time + 14.minutes + 59.seconds
    Time.stub(:now, nearly_expired) do
      assert manager.valid_session?('session_55') # Should still be valid
    end

    # Simulate moving 15 minutes and 1 second forward.
    expired = start_time + 15.minutes + 1.second
    Time.stub(:now, expired) do
      refute manager.valid_session?('session_55') # Should be expired
    end
  end
end

This approach lets me test time-sensitive logic—session expiry, cache invalidation, job scheduling—instantly and deterministically. It’s one of the most powerful mocking techniques I use.

Our systems need to be robust. They need to handle network failures, timeouts, and remote service errors gracefully. To test this, I don’t want to actually break a network. I want to simulate failure on demand. This is fault injection.

I create mocks that can be configured to raise exceptions or return errors on specific calls, or after a certain number of attempts. This lets me test retry logic, circuit breakers, and fallback mechanisms.

class DataSyncClientTest
  def test_retries_on_transient_failure
    unreliable_api = FaultyMock.new

    client = DataSyncClient.new(api: unreliable_api, max_retries: 2)

    # Configure the mock to fail twice, then succeed.
    call_count = 0
    unreliable_api.stub(:fetch_data) do
      call_count += 1
      if call_count <= 2
        raise Net::OpenTimeout, 'Connection timed out'
      else
        { data: [1, 2, 3] }
      end
    end

    result = client.safe_fetch

    assert result[:success]
    assert_equal [1, 2, 3], result[:data]
    assert_equal 3, call_count # Verified it retried twice.
  end

  def test_circuit_breaker_opens_after_failures
    dead_api = FaultyMock.new
    circuit = CircuitBreaker.new(failure_threshold: 3, reset_timeout: 60)

    client = DataSyncClient.new(api: dead_api, circuit_breaker: circuit)

    # The API is completely dead.
    dead_api.stub(:fetch_data) { raise SocketError }

    # First three calls try and fail.
    3.times do
      assert_raises(SocketError) { client.fetch }
    end

    # The fourth call should be stopped immediately by the open circuit.
    assert_raises(CircuitOpenError) { client.fetch }

    # Simulate time passing after the reset timeout.
    Time.stub(:now, Time.now + 70) do
      # The circuit should be half-open and allow a test call.
      # Let's make the api recover for this test.
      dead_api.stub(:fetch_data) { { data: 'recovered' } }
      result = client.fetch
      assert result[:success]
    end
  end
end

The FaultyMock in these tests is just a conditional stub with state, tracking call counts to decide when to fail. Testing resilience is impossible without this ability to precisely control failure modes.

Testing database transactions presents a unique challenge. You need to ensure that a series of operations either all succeed or all fail, without leaving test data in a real database. I mock the transaction mechanism itself.

The idea is to create a mock database connection that tracks operations within a transaction block and provides methods to verify atomicity.

class AccountTransferServiceTest
  def test_transfer_rolls_back_on_failure
    mock_db = TransactionalMock.new

    service = AccountTransferService.new(db: mock_db)

    # Set up the scenario: debit succeeds, credit fails.
    mock_db.stub(:debit_account) { true }
    mock_db.stub(:credit_account) { raise 'Insufficient Funds' }

    # The entire transfer should fail.
    assert_raises(TransferFailedError) do
      service.transfer_funds(from: 'acc_a', to: 'acc_b', amount: 500)
    end

    # The verification is key: did the mock roll back?
    assert mock_db.rolled_back?
    # Since it rolled back, no operations should be "committed."
    assert_empty mock_db.committed_operations
  end
end

The mock database needs to understand the concept of a transaction.

class TransactionalMock
  def initialize
    @current_transaction_ops = []
    @committed_ops = []
    @in_transaction = false
    @rollback_called = false
  end

  def transaction
    @in_transaction = true
    @current_transaction_ops = []
    @rollback_called = false

    begin
      yield self # Your code runs inside this block.
      commit! unless @rollback_called
    ensure
      @in_transaction = false
    end
  end

  def debit_account(account_id, amount)
    check_in_transaction!
    # Record the attempted operation.
    @current_transaction_ops << [:debit, account_id, amount]
    # Then execute the stubbed behavior.
    # ... stub logic would run here ...
  end

  def rollback
    @rollback_called = true
    @current_transaction_ops = []
  end

  def commit!
    @committed_ops.concat(@current_transaction_ops)
  end

  def rolled_back?
    @rollback_called
  end

  def committed_operations
    @committed_ops
  end

  private

  def check_in_transaction!
    raise 'Operation outside of transaction!' unless @in_transaction
  end
end

This mock lets me test the atomicity guarantee of my service layer without ever touching a real database, making the tests fast and reliable.

Modern applications are often built around events. Something happens (an order is placed), and multiple, independent parts of the system need to react (update inventory, send confirmation, log for analytics). Testing this involves verifying that events are published and that the right subscribers get them.

I mock the event bus. The mock’s job is to track what was published and to which subscribers it was delivered.

class OrderServiceTest
  def test_order_creation_publishes_event
    event_bus = MockEventBus.new
    inventory_handler = Spy.new
    analytics_handler = Spy.new

    # Subscribe our spies to the event.
    event_bus.subscribe('order.created', inventory_handler)
    event_bus.subscribe('order.created', analytics_handler)

    service = OrderService.new(event_bus: event_bus)

    order = service.create_order(customer_id: 33, total: 99.99)

    # Verify the event was published with the correct data.
    assert event_bus.published?('order.created', order_id: order.id, total: 99.99)

    # Verify each subscriber was notified.
    assert inventory_handler.received?('order.created')
    assert analytics_handler.received?('order.created')

    # Verify the data the subscriber got.
    payload = inventory_handler.last_payload('order.created')
    assert_equal order.id, payload[:order_id]
  end
end

The MockEventBus is a combination of a spy (it records publications) and a router (it manages subscriptions).

class MockEventBus
  def initialize
    @subscriptions = Hash.new { |h, k| h[k] = [] }
    @publication_log = []
  end

  def subscribe(event_type, handler)
    @subscriptions[event_type] << handler
  end

  def publish(event_type, payload)
    # Record the event.
    @publication_log << { type: event_type, payload: payload.dup, time: Time.now }

    # Notify all subscribers.
    @subscriptions[event_type].each do |handler|
      handler.handle(event_type, payload.dup)
    end
  end

  def published?(event_type, **matching_payload)
    @publication_log.any? do |event|
      next unless event[:type] == event_type
      # Check if all key-value pairs in matching_payload exist in the event payload.
      matching_payload.all? { |key, value| event[:payload][key] == value }
    end
  end
end

This pattern gives me high confidence that my event-driven architecture is wired correctly. I can test that events are published at the right time with the right data, and that the system’s responses are properly decoupled.

Finally, when I find myself setting up the same kind of mock in many different tests, I build a configurable mock. It takes a hash of method names and their desired responses (which can be static values or callable blocks). This centralizes mock behavior and makes tests cleaner.

# In a test helper or factory
def create_mock_user_repository(users: {}, find_behavior: ->(id) { users[id] })
  ConfigurableMock.new(
    find: find_behavior,
    save: ->(user) { user[:id] = rand(1000); user },
    delete: true,
    default_response: nil # What to return for unconfigured methods
  )
end

# In a test
def test_user_processing
  user_data = { 1 => { name: 'Alice' }, 2 => { name: 'Bob' } }
  user_repo = create_mock_user_repository(users: user_data)

  processor = UserProcessor.new(repo: user_repo)
  result = processor.process_ids([1, 2])

  assert_equal ['Alice', 'Bob'], result
  assert user_repo.called?(:find, times: 2)
end

The ConfigurableMock itself is a generic shell that uses method_missing to look up responses in its configuration hash.

class ConfigurableMock
  def initialize(config = {})
    @config = config
    @calls = []
  end

  def method_missing(method_name, *args, &block)
    @calls << { method: method_name, args: args }

    if @config.key?(method_name)
      response = @config[method_name]
      response.respond_to?(:call) ? response.call(*args, &block) : response
    elsif @config.key?(:default_response)
      @config[:default_response]
    else
      nil
    end
  end

  def called?(method_name, times: nil)
    count = @calls.count { |c| c[:method] == method_name }
    times ? count == times : count > 0
  end
end

This is the ultimate tool for reducing boilerplate. It turns mock setup from a series of imperative statements into a declarative configuration, which is much easier to read and maintain.

These patterns have transformed how I write tests for complex Ruby code. They move me from just checking return values to specifying and verifying interactions and behaviors. They help me write tests that are themselves clear documentation of how the system should work under various conditions—with order, with time, with failure, and with events. The goal is never mocking for its own sake, but to create a precise, fast, and reliable safety net that lets me change my production code with confidence. Start with a simple stub, but when you need more control, remember you have these patterns in your toolbox.

Keywords: ruby mocking, advanced testing patterns, mock objects ruby, stub testing techniques, ruby test isolation, sequence testing ruby, conditional stubbing, time mocking ruby, fault injection testing, transaction testing ruby, event driven testing, spy pattern testing, fluent mocking ruby, test driven development, ruby minitest patterns, rspec advanced mocking, payment gateway testing, email testing ruby, workflow testing, ruby testing best practices, mock verification patterns, test doubles ruby, behavior verification testing, ruby unit testing, integration testing mocks, ruby test frameworks, mock object patterns, testing external apis, ruby testing strategies, complex testing scenarios, ruby test automation, mock configuration ruby, testing ruby services, ruby testing patterns, advanced ruby testing, mock library alternatives, testing ruby applications, ruby testing techniques, test isolation strategies, ruby mock frameworks, testing time dependent code, testing failure scenarios, testing database transactions, testing event systems, ruby testing anti patterns, mock object design, testing ruby workflows, ruby testing methodologies, testing third party integrations, ruby testing tools, mock object lifecycle, testing asynchronous code, ruby testing examples, testing ruby classes, mock object verification, testing ruby modules, ruby testing guidelines, testing payment processing, testing email delivery, testing audit logs, testing user authentication, testing data synchronization, testing circuit breakers, testing retry logic, testing session management, testing tax calculations, testing order fulfillment, testing compliance systems, testing inventory management, testing shipping services



Similar Posts
Blog Image
7 Essential Ruby Gems for Building Powerful State Machines in Rails Applications

Discover 7 powerful Ruby gems for Rails state machines. Learn AASM, StateMachines, Workflow & more with code examples. Improve object lifecycle management today.

Blog Image
Is Your Ruby Code Wizard Teleporting or Splitting? Discover the Magic of Tail Recursion and TCO!

Memory-Wizardry in Ruby: Making Recursion Perform Like Magic

Blog Image
8 Advanced Ruby on Rails Techniques for Building a High-Performance Job Board

Discover 8 advanced techniques to elevate your Ruby on Rails job board. Learn about ElasticSearch, geolocation, ATS, real-time updates, and more. Optimize your platform for efficiency and user engagement.

Blog Image
Why Is Testing External APIs a Game-Changer with VCR?

Streamline Your Test Workflow with the Ruby Gem VCR

Blog Image
7 Essential Rails Service Object Patterns for Clean Business Logic Architecture

Master 7 Rails service object patterns for clean, maintainable code. Learn transactional integrity, dependency injection, and workflow patterns with real examples. Build robust apps today.

Blog Image
Is Draper the Magic Bean for Clean Rails Code?

Décor Meets Code: Discover How Draper Transforms Ruby on Rails Presentation Logic