Mocking and Stubbing with Minitest
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
- Minitest Basics — Start here for Minitest fundamentals and assertion basics
- Error Handling in Ruby — Understanding exceptions for testing error cases
- Enumerable#reduce — reduce is often used in stub blocks for aggregation
- Ruby Procs and Lambdas — Blocks and lambdas often used in stub blocks