Doubles and stubs
Why a double
A unit test should fail for one reason. A test of a class that calls a network client and fails on a slow day fails for two: the logic and the network. A double replaces the client with a value the test controls, so the only thing under test is the logic:
class Weather
def initialize(client) = @client = client
def forecast(city) = @client.get("/weather/#{city}")
end
The constructor takes the client, which is the design that makes the test possible. A class that creates its own Net::HTTP inside the method cannot be tested this way, and that is the reason to inject the dependency.
Minitest::Mock
A mock states the call in advance: which method, what it returns and which arguments are allowed. verify fails the test when the call never happened:
require "minitest/autorun"
require "minitest/mock"
class WeatherTest < Minitest::Test
def test_forecast_asks_the_client
client = Minitest::Mock.new
client.expect(:get, "sunny", ["/weather/Ada"])
assert_equal "sunny", Weather.new(client).forecast("Ada")
client.verify
end
end
The mock checks the protocol, not just the answer: a wrong path or a missing call fails. Minitest 6 moved the mock into the minitest-mock gem, so the install is one more line on a modern Ruby:
gem install minitest-mock
1 gem installed
stub
stub replaces one method on a real object for the length of a block, then puts the original back. It is the lighter tool when the test only needs an answer, not a promise about the call:
def test_stub_replaces_one_method
client = Object.new
def client.get(_path) = "real"
client.stub(:get, "rain") do
assert_equal "rain", Weather.new(client).forecast("Ada")
end
assert_equal "real", client.get("/weather/Ada")
end
The method must exist before it can be stubbed; the first version of this test created a bare Object.new and failed with undefined method 'get'. Defining the real method first is also more honest: the double stands in for something that has a shape.
The limits
A test full of mocks checks that the code calls the methods the test expects, not that the program works. Two rules keep it useful:
- Mock at the boundary. A client, a clock, a random source. Not the class under test's own private methods.
- Keep one integration test. The double proves the logic; one test against the real client proves the pair still fits.
The reader should be able to delete a mock and name the real collaborator it stands for. A mock that replaces a method invented by the test is a test of the test.
What to watch for
stub restores the method at the end of the block,
even when the block raises. A stub that leaks past the test makes the next
test pass for the wrong reason, and it is the reason the block form is
required.
A mock with a strict argument list fails when the code passes an extra
keyword. That is the point, and also the usual reason a harmless
refactor breaks a suite of mocks.
Next: Coverage
Article author: Arthur Isaev