Skip to content
End To End Tester

Test Doubles

Dummy, 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.

3 min read · updated 19 September 2026

Gerard Meszaros named five in xUnit Test Patterns, and the names have held up. Most confusion in testing conversations comes from "mock" being used for all five.

Kind Has behaviour? Test asserts on it? Typical use
Dummy no no fills a required parameter
Stub canned answers no supplies input to the code under test
Spy records calls yes, afterwards checks a side effect happened
Mock pre-programmed expectations yes, built in strict interaction checking
Fake real, simplified usually on its state replaces a slow dependency

#Dummy

An object that is passed but never used. It exists because a constructor demands it.

typescript
// The logger is never called in this path; it is here to satisfy the signature.
const service = new OrderService(gateway, repository, null as unknown as Logger);

If you find yourself writing dummies often, the constructor is doing too much. See writing testable code.

#Stub

Supplies input. The test does not care that it was called, only that the code under test received something specific to work with.

python
# Python. The clock is a stub: it exists so "now" is a known value.
class FrozenClock:
    def now(self): return datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)

def test_token_expires_after_an_hour():
    token = issue_token("alice", clock=FrozenClock())

    assert token.expires_at == datetime(2026, 1, 1, 13, 0, tzinfo=timezone.utc)

The assertion is on the result, not the clock. That makes it a stub.

#Spy

Records what happened so the test can check afterwards. Use one when the side effect is the behaviour and there is no return value to assert on.

typescript
// TypeScript. A hand-rolled spy: no framework needed.
class RecordingMailer implements Mailer {
  readonly sent: Message[] = [];
  async send(message: Message) { this.sent.push(message); }
}

it('emails the customer once the order ships', async () => {
  const mailer = new RecordingMailer();

  await new Shipping(mailer).ship(order);

  expect(mailer.sent).toHaveLength(1);
  expect(mailer.sent[0].subject).toContain('on its way');
});

Note how much this reads like a state assertion. That is deliberate, and it is why spies age better than mocks: the test says an email like this went out, not this method was called with these arguments.

#Mock

A double with expectations set in advance, which fails the test if they are not met — often including call counts and ordering.

csharp
// C#, Moq. Strict: any unconfigured call is a failure.
var gateway = new Mock<IPaymentGateway>(MockBehavior.Strict);
gateway.Setup(g => g.ChargeAsync(It.Is<ChargeRequest>(r => r.Cents == 4_000)))
       .ReturnsAsync(ChargeResult.Succeeded("pi_1"))
       .Verifiable();

await new Checkout(gateway.Object).PayAsync(order);

gateway.Verify();

Mocks are the most precise and the most brittle. They are the right tool when the interaction itself is the contract — "we must call the fraud service before we capture the payment, and exactly once" is a real business rule and deserves a mock. They are the wrong tool for "the repository saved it", which is a statement about state dressed up as one about calls.

Strict mocks in particular should be rare. A strict mock fails when the code makes a call you did not anticipate, which means every new collaboration breaks every existing test.

#Fake

A real implementation, simplified. In-memory repositories, an SMTP server that collects mail in a list, SQLite standing in for Postgres, or WireMock serving recorded HTTP responses.

Fakes have genuine behaviour, which is their advantage and their risk: they can be exercised the way the real thing is, and they can drift from it. The standard mitigation is one shared test suite run against both the fake and the real implementation.

java
// Java. Same tests, two implementations — the fake cannot drift silently.
abstract class OrderRepositoryContract {
    abstract OrderRepository repository();

    @Test void saves_and_reads_back() { /* ... */ }
    @Test void find_is_case_insensitive_on_reference() { /* ... */ }
}

class InMemoryOrderRepositoryTest extends OrderRepositoryContract {
    OrderRepository repository() { return new InMemoryOrderRepository(); }
}

class PostgresOrderRepositoryTest extends OrderRepositoryContract {
    // @Testcontainers — the real thing, one container for the class
    OrderRepository repository() { return new PostgresOrderRepository(dataSource); }
}

#Choosing

A workable default:

  • Needs a value back, do not care that it was asked → stub
  • Side effect is the behaviour, want to check it happened → spy
  • The call sequence is itself the rule → mock
  • Dependency is slow or has awkward setup, and you want real behaviour → fake
  • Parameter you must supply and nothing touches → dummy

And one negative rule that resolves most reviews: if the test asserts on the double rather than on the outcome, ask what would break if the implementation changed shape. If the answer is "this test, and nothing else", the double is too strict.

Where to go next: unit testing with mocks for the isolationist case, without mocks for the sociable one, and mocking frameworks for the libraries that build these in each ecosystem.

Common questions

What is the difference between a mock and a stub?
A stub supplies canned answers so the code under test can proceed; the test never asserts on it. A mock is a stub that also records how it was called, and the test asserts on those recordings. The distinction is about where the assertion lives, not about which library made the object.
What is a fake?
A working implementation that is unsuitable for production — an in-memory repository, a Postgres running in a container, a mail sender that appends to a list. It has real behaviour, which is what separates it from a stub.
Do these distinctions actually matter day to day?
They matter in review. "This is a mock where a stub would do" is a precise, actionable comment that takes five words; without the vocabulary the same comment takes a paragraph and usually is not made.

Runnable samples for this page

last test results ↗
  • TypeScripttypescript/src/testing-levels/test-doubles

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?