QA automation path · Foundations · Topic 3 of 12

Test doubles: stubs, spies, mocks and fakes

Not started

A test double stands in for a real dependency so a test can run fast and give the same result every time. The names come from Gerard Meszaros: a **dummy** only fills a parameter, a **stub** returns canned answers, a **spy** also records how it was called, a **mock** is set up in advance with the calls it should get, and a **fake** is a working but simplified implementation, such as an in-memory database.

Why it matters: Doubles make a unit test fast and repeatable. Used too freely, they make tests pass against a world that doesn't exist.

  1. 1Five kinds

    A dummy fills a parameter. A stub returns canned answers. A spy also records its calls. A mock is set up with the calls it expects. A fake is a working but simplified implementation.

  2. 2State or behaviour

    Either check the resulting state (what was saved, what was returned) or check the calls made to collaborators. Prefer state; it couples less to the implementation.

  3. 3Double the boundary

    Replace clocks, randomness, networks and third-party services. Keep your own logic real.

  4. 4Doubles can lie

    A stub of another service only knows what you told it. Something else, like a contract or integration test, has to check that the real service still behaves that way.

1

How it works

The explanation, with examples you can run.

Three doubles in one test

The sign-up service below takes its dependencies as parameters. The test passes in a fake user store, a stub clock and a spy mailer, then checks the outcome. No database, no real time, no email.

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

The fake is what makes the second call meaningful: because it really stores the first user, the "taken" path is tested without a hand-written stub for each case.

With a framework

Vitest and Jest come with spies, mocks and fake timers built in. The same ideas, less code:

import { expect, test, vi } from "vitest"import { fakeUsers, makeSignup } from "./signup" // the functions from the example above test("sends one welcome email", async () => {  vi.useFakeTimers()  vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))  const send = vi.fn().mockResolvedValue(undefined)   await makeSignup({ users: fakeUsers(), mailer: { send }, clock: { now: () => new Date().toISOString() } })("ada@example.com")   expect(send).toHaveBeenCalledTimes(1)  expect(send).toHaveBeenCalledWith("ada@example.com", "Welcome!")  vi.useRealTimers()})

When doubles hurt

  • Over-mocking: if a test replaces every collaborator, it tests only that the code calls things in a certain order. Refactor, and it fails. Introduce a real bug in a collaborator, and it still passes.
  • Mocking what you don't own: hand-written stubs of a third-party SDK capture what you think it does. Wrap the SDK in a small adapter of your own, double the adapter in unit tests, and test the adapter itself against the real thing (or a container) in an integration test.
  • Stale stubs of other services: every stubbed HTTP response is a claim about another team's API. Contract tests are how you keep those claims true.

A useful rule: double things that are slow, non-deterministic or outside your control. Keep plain logic and value objects real.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.An in-memory repository with working save and find methods, used in place of the database in tests, is a:
  2. 2.Your tests stub the payment provider's HTTP response. The provider renames a field. What happens?
  3. 3.Which kind of double is given its expected calls before the act step, and fails the test itself when they don't happen?

0 of 3 right so far. Get all three to finish this topic.