Mocking and Stubbing with Minitest

· 4 min read · Updated March 16, 2026 · intermediate
minitest mocking stubbing testing tdd ruby

Real-world Ruby code rarely lives in isolation. It calls APIs, queries databases, sends emails, and interacts with external services. Mocking lets you test these interactions without the complexity of the real thing. Minitest provides built-in tools for this.

When to Mock

Mocking shines when you need to:

  • Test code that depends on slow external services
  • Simulate edge cases that are hard to trigger in reality
  • Isolate a unit of code from its dependencies
  • Speed up your test suite by avoiding real I/O

Don’t mock everything. Mock the boundaries—where your code touches the outside world.

Mock Objects

Minitest::Mock creates fake objects that verify interactions:

def test_processes_payment
  payment_gateway = Minitest::Mock.new
  payment_gateway.expect :charge, true, [100]
  
  order_processor = OrderProcessor.new(payment_gateway)
  result = order_processor.process_order(order_with_total(100))
  
  assert result.success?
  payment_gateway.verify  # Raises if :charge was not called
end

The expect method defines what method to call, what it returns, and with what arguments. The verify at the end ensures your code actually called the mock.

Multiple Expectations

Chain expectations to test sequences:

def test_sends_notifications
  email_service = Minitest::Mock.new
  email_service.expect :send_welcome, true, ["user@example.com"]
  email_service.expect :send_intro, true, ["user@example.com"]
  
  onboarding = Onboarding.new(email_service)
  onboarding.start(user)
  
  email_service.verify
end

Expectations must be called in order. If send_intro gets called before send_welcome, the test fails.

Stubbing Methods

Stubs replace a method’s return value without creating a full mock:

def test_uses_cached_data
  result = Cache.stub :get, "cached value" do
    DataFetcher.fetch(:users)
  end
  
  assert_equal "cached value", result
end

The stub yields to its block with the method replaced. After the block, the original method is restored.

Stubbing Constants

Stub constants for testing code that checks environment variables or configuration:

def test_uses_production_url_in_production
  ENV.stub :[], "production" do
    client = ApiClient.new
    assert_equal "https://api.production.com", client.base_url
  end
end

Stubbing Time

Test time-sensitive code by stubbing Time.now or Date.today:

def test_subscription_expired
  Time.stub :now, Time.new(2025, 1, 15) do
    subscription = Subscription.new(expires_at: Date.new(2025, 1, 1))
    assert subscription.expired?
  end
end

Spies

Spies record how a real object was used, without requiring you to set up expectations upfront:

def test_logger_records_error
  logger = Logger.new
  
  begin
    risky_operation
  rescue => e
    logger.error(e.message)
  end
  
  assert logger.errors.include?("Something went wrong")
end

Minitest spies work by wrapping a real object and tracking method calls. You assert after the fact what got called.

Partial Mocks

Partial mocks (mocks that are real objects with some methods stubbed) work with Minitest::Mock.new applied to existing objects:

def test_processes_with_retry
  api = RealApiClient.new
  
  api.stub :fetch, { data: "stubbed" } do
    result = api.fetch("/items")
    assert_equal "stubbed", result[:data]
  end
  
  # Original fetch method is restored here
end

Common Patterns

Testing HTTP Calls

def test_fetches_user_data
  stub_request(:get, "https://api.example.com/users/1")
    .to_return(body: { name: "Alice" }.to_json)
  
  user = UserFinder.new.find(1)
  
  assert_equal "Alice", user.name
end

This uses WebMock (install via gem install webmock) to intercept HTTP requests.

Testing File Operations

def test_reads_config
  File.stub :read, "debug: true" do
    config = ConfigLoader.load
    assert_equal true, config.debug?
  end
end

Testing Database Calls

def test_creates_user
  User.stub :create, build_user(id: 42) do
    result = UserCreator.create(name: "Bob")
    assert_equal 42, result.id
  end
end

Anti-Patterns to Avoid

Stubbing Everything

Don’t stub internal implementation details. If you’re stubbing private methods or deeply nested dependencies, your test is too coupled to implementation:

# Bad: Testing implementation, not behavior
def test_calculates_discount
  calculator.stub :apply_coupon, 10 do
    assert_equal 90, calculator.final_price(100)
  end
end

Instead, test the public interface:

# Better: Testing behavior
def test_applies_discount
  result = order.final_price(coupon_code: "SAVE10")
  assert_equal 90, result
end

Over-Mocking

If your test needs five mocks, the code under test probably does too much. Break it into smaller pieces that you can test in isolation.

Forgetting Verify

Never leave out mock.verify—it’s the only thing ensuring your code actually called the dependency:

# This test passes even if payment_gateway.charge is never called!
def test_processor
  payment_gateway = Minitest::Mock.new
  payment_gateway.expect :charge, true, [100]
  
  processor = OrderProcessor.new(payment_gateway)
  # Oops, forgot to call processor.charge!
  
  # payment_gateway.verify  # <- Always add this!
end

Summary

Mocking is a skill that improves with practice. Start with simple cases like stubbing time or config, then move to mock objects as your tests need more confidence. Remember: the goal is not 100% mock coverage—it is testing behavior, not implementation.

Mocking and stubbing let you test code that depends on things you don’t want in your test environment. Minitest gives you Minitest::Mock for creating fake objects and stub for replacing individual methods. Use mocks to verify interactions, stubs to provide values, and always verify your mocks.

See Also