Mocking and Stubbing in Ruby with Minitest: Mocks, Stubs, and Spies
Mocking and stubbing let you test Ruby code without real external dependencies. 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, and Minitest provides built-in tools for this.
Intro context
Mocking and stubbing are easiest to understand when you separate the thing you want to test from the thing that makes it slow or unpredictable. If your code depends on an API, a queue, or a file system call, a mock or stub lets you keep the test focused on the behavior you actually care about.
That does not mean every dependency should be faked. The best tests still use real objects when the interaction is simple and the cost is low. Mocks are most useful when they keep a test fast, reliable, and easy to read.
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.
That last step is the part people forget most often. A mock that never gets verified can silently pass even when the code under test did the wrong thing. Verification is what turns the setup into a meaningful assertion.
That last step is the part people forget most often. A mock that never gets verified can silently pass even when the code under test did the wrong thing. Verification is what turns the setup into a meaningful assertion.
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. This ordered verification is useful when the sequence itself matters, such as in onboarding flows or multi-step API calls. When order is not part of the behavior you care about, prefer a looser assertion so the test does not become brittle.
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.
That temporary replacement is one of the reasons stubs work so well for small test scopes. You can isolate just the one method you need, exercise the code path, and then move on without leaving the object in a changed state.
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 constants like ENV is useful for testing code that reads environment variables or configuration. The stub temporarily replaces the value so your test can control the environment without modifying the actual system state. After the block completes, the original constant is restored.
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
Stubbing Time.now or Date.today is one of the most common testing patterns in Ruby. It lets you freeze the clock inside a test so time-sensitive code produces predictable results. Without this technique, tests that depend on the current date would produce different outcomes depending on when they run.
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. Spies are a good fit when you care more about the side effect than the exact setup — they let the code run with a real object and then check what happened afterward.
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
Partial mocks let you stub a few methods on an otherwise real object. This is useful when the object under test has one slow or external dependency but the rest of its behavior is straightforward. After the stub block ends, the original methods are restored, so other tests are not affected.
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.
That is a useful design smell to pay attention to during refactoring. Heavy mocking often points to a method that knows too much or has too many responsibilities. If you split that work into smaller methods, the tests usually get simpler too.
That is a useful design smell to pay attention to during refactoring. Heavy mocking often points to a method that knows too much or has too many responsibilities. If you split that work into smaller methods, the tests usually get simpler too.
Forgetting Verify
Never leave out mock.verifyit’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.
If you want one simple rule to remember, make it this: mock the boundary, not the whole program. A tiny test double at the edge of your code is usually enough to keep the rest of the test honest.
Forward link
If you want to see how this style of testing fits into a broader workflow, continue with Integration Testing in Ruby. Integration tests help you decide which dependencies are worth mocking and which ones should stay real.
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