Skip to content
End To End Tester

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.

csharp
// 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
// 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 ↗
  • TypeScripttypescript/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?