Hand-Rolled Test Doubles and Isolation
Stubs, fakes, mocks without a mocking library โ and the shared-state bug that makes tests flaky.
Why doubles at all
A unit test must not need the network, a database, or the file system. It needs the behavior of those things, controlled by the test. A test double is a stand-in object that provides it. The dependency-inversion you built in Module 11 โ services depend on interfaces โ is exactly what makes doubles possible: the test hands the service a different implementation.
The spectrum, by hand
- Stub โ returns hardcoded answers. "The payment gateway approves." No state worth checking.
- Fake โ a working lightweight implementation. A
Dictionary-backed repository stands in for the SQL one. The service can't tell the difference, and the test can inspect real behavior. - Mock โ records the interaction for later verification: "Reserve was called exactly once with sku=tea". Use when the interaction itself is the requirement (side effects), not the data.
public interface IPaymentGateway { bool Charge(decimal amount); }
public sealed class StubGateway : IPaymentGateway // stub
{
public bool Result = true;
public bool Charge(decimal amount) => Result;
}
public sealed class MockGateway : IPaymentGateway // mock
{
public int Calls;
public decimal LastAmount;
public bool Charge(decimal amount) { Calls++; LastAmount = amount; return true; }
}
Fakes are the workhorse; mocks pull their weight when the call is the outcome. Reaching for a mocking library before writing two by hand is how people end up testing the mock instead of the code.
Isolation: every test gets a fresh world
The classic rot: tests pass alone, fail in a suite โ or the reverse. Root causes are almost always shared mutable state:
- a
staticfield or cache on the service; - a singleton the service mutates;
- ordering: test B depends on something test A created.
Rules that prevent it: fresh instances per test (the Arrange block builds its own world), no statics holding state, and no test may depend on another having run. In real frameworks, test order is undefined โ anything that needs ordering is a bug.
Check your understanding
- Stub vs fake? (Stub answers canned values; fake is a working lightweight implementation.)
- When is a mock the right choice? (When the interaction itself โ was it called, with what โ is the requirement.)
- Tests pass alone, fail together. First suspects? (Shared static state, singletons, hidden ordering.)