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:

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