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.