Unit Testing with Mocks
The solitarist approach — isolate the unit behind test doubles. What it buys, the coupling it creates, and the three rules that keep a mock-heavy suite maintainable.
3 min read · updated 19 September 2026
The solitarist position: a unit test should exercise one unit, and every
collaborator it talks to should be replaced by a test
double. If OrderService depends on
IPaymentGateway, IInventoryRepository and IClock, all three are
doubles, and the test is about OrderService alone.
It is also called the London school, or mockist testing, and it is the approach most closely associated with TDD as originally taught in Growing Object-Oriented Software, Guided by Tests.
#What it buys
Precision. The test fails when OrderService is wrong and at no other
time. A bug in InventoryRepository breaks the inventory tests, not the
order tests, so the failure list points directly at the cause.
Speed and determinism, unconditionally. Nothing real is involved, so there is no clock, no I/O and no shared state. See flaky tests for how much that is worth.
Design pressure. You cannot mock a dependency you cannot substitute, so this style forces dependency injection and explicit interfaces. That pressure is the real point: a codebase that has been mock-tested for a year tends to have clean seams, because the alternative was intolerable.
Testing what has not been built. A collaborator that is a double does not have to exist yet.
// C#, xUnit + Moq. The gateway does not exist; the contract does.
[Fact]
public async Task Charges_the_card_once_the_order_is_reserved()
{
var gateway = new Mock<IPaymentGateway>();
gateway.Setup(g => g.ChargeAsync(It.IsAny<ChargeRequest>()))
.ReturnsAsync(ChargeResult.Succeeded("pi_123"));
var inventory = new Mock<IInventoryRepository>();
inventory.Setup(i => i.ReserveAsync("sku-1", 2)).ReturnsAsync(true);
var service = new OrderService(gateway.Object, inventory.Object, new FixedClock(Jan1));
var result = await service.PlaceAsync(new Order("sku-1", quantity: 2, cents: 4_000));
Assert.True(result.Succeeded);
Assert.Equal("pi_123", result.PaymentIntentId);
}#What it costs
Coupling to the implementation. This is the whole objection, and it is a
serious one. The test above knows that OrderService calls
ReserveAsync before ChargeAsync, with those arguments, on those
interfaces. Change the collaboration — inline the repository, split the
gateway, reorder the calls — and the test fails even though the behaviour
is identical. You have written a test that resists refactoring, which is the
opposite of what tests are for.
Doubles that lie. A stub returns what you told it to return. If the real
IPaymentGateway throws a RateLimitException on the third call in a
second, your test never finds out, and neither do you until production. The
double encodes your belief about the dependency, and that belief is
frequently wrong. This is the strongest argument for backing mock-heavy
units with real integration tests and
contract tests.
Tests that assert nothing. verify(repo).save(order) passes whether or
not the order was correct, whether or not it was saved, and whether or not
anybody ever reads it back. It asserts that a method was called. In a large
suite this accumulates into a body of tests that pass reliably and catch
nothing.
#Three rules that keep it sane
1. Mock roles, not objects. Double the things that represent a
collaboration — a gateway, a notifier, a clock. Do not double value
objects, data structures, or pure functions you own. new Money(500) is
always cheaper and more honest than a mocked IMoney.
2. Stub queries, verify commands. A call that asks something and returns a value should be stubbed and then forgotten — asserting that you asked is noise. A call that does something with no return value is often the only observable outcome, and verifying it is legitimate.
// TypeScript, Jest. The read is stubbed and never verified;
// the send is the behaviour, so it is verified.
const rates = { forDate: jest.fn().mockResolvedValue({ gbp: 0.79 }) };
const mailer = { send: jest.fn().mockResolvedValue(undefined) };
await new Invoicer(rates, mailer).issue(invoice);
expect(mailer.send).toHaveBeenCalledWith(
expect.objectContaining({ to: '[email protected]', totalGbp: 79_00 })
);3. One double per test that matters. If a test needs five doubles configured, the unit has five collaborators and the test is telling you something about the design. Listen to it — see writing testable code.
#Where the honest boundary is
Mock at the process boundary and use real objects inside it. Your own pure domain objects, value types and small helpers should be real; the database, the HTTP client, the message broker, the clock and the filesystem should be doubles at the unit level and real at the integration level.
That rule dissolves most of the London/Detroit argument, because it is what both camps do in practice once the codebase is large enough. The genuinely contested territory is your own repositories and services, and there the answer depends on how stable their interfaces are: stable seam, mock it; churning seam, use the real thing and read unit testing without mocks.
For the mechanics — how to build doubles in each ecosystem — see mocking frameworks, Moq and jest mocking.
Common questions
- What is the difference between the London school and the Detroit school of TDD?
- The London (mockist, solitarist) school isolates each unit behind doubles and tests the interactions between them; the Detroit (classicist, sociable) school lets real collaborators participate and tests the resulting state. In practice most working codebases mix the two — doubles at the process boundary, real objects inside it.
- When should I mock something?
- Mock at the edges — things that are slow, non-deterministic, or have side effects you do not want in a test. Do not mock things you own that are pure and fast; using the real object is cheaper to write and far cheaper to maintain.
- Why do my tests break every time I refactor?
- Almost always because they assert on interactions rather than outcomes. A test that verifies "the repository's Save was called once with this object" is coupled to the shape of the implementation, so any change to that shape breaks it even when the behaviour is unchanged.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/testing-levels/unit-testing-with-mocks
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Unit Testing without MocksThe sociable approach — real collaborators, state-based assertions, and tests that survive refactoring. What it buys, where it breaks down, and how to keep it fast.
- Test DoublesDummy, stub, spy, mock and fake — Meszaros's five kinds of test double, what each is for, and why using the words precisely makes code reviews shorter.
- Mocking FrameworksMoq, NSubstitute, Mockito, unittest.mock, Sinon and the rest — what each ecosystem's mocking library does well, and the failure modes they share.
- MoqThe .NET mocking library in practical detail — setups, argument matchers, verification, loose versus strict behaviour, and what to use instead where it strains.
- Jest MockingModule mocks, spies, manual mocks and timer control — how Jest's mocking works, and the patterns that keep it from taking over a suite.
- Dependency Injection for TestabilityWhy injected dependencies are the one technique that makes isolated testing possible, the three forms of injection, and how to do it without a container.
- Mutation TestingDeliberately break the production code and see whether the tests notice — the only practical measurement of whether a test suite actually asserts anything.
- Test-Driven DevelopmentRed-green-refactor, what TDD actually changes about a codebase, where it fits badly, and the honest evidence for and against it.