Skip to content
Episode 92 · 140 green tests, 61 failed refunds

Mocks, fakes and fixtures: testing without lying to yourself

Tuesday, 09:15: partial refunds shipped yesterday with 140 green tests. Overnight, all 61 real refunds failed: the payment provider wants a field our mock never asked for, in a format it never checked.

Fast tests need stand-ins for slow, random or risky things. Used well, they speed you up. Used badly, they hide real bugs.

30 min intermediateChoosing a test double: stub, mock, fake or spyWhen mocks lie: doubles drifting from realityFixtures, factories and test data managementChoosing the cheapest test level that catches a bugTesting pyramid: balancing unit, integration, contract and e2eFlaky tests and keeping test results trustworthy
  1. Problem

    Your booking code charges cards, sends emails and talks to a database. Tests can't do that for real a thousand times a minute.

  2. Why the simple system fails

    Calling real dependencies is slow, flaky and dangerous (real charges, real emails). Mocking everything is fast, but the suite stays green while production breaks.

  3. Engineering concept

    Test doubles stand in for dependencies at a boundary you own; factories build valid data; anything a double pretends must be verified against reality somewhere else.

  4. Technology

    Vitest stubs, mocks (vi.fn) and spies (vi.spyOn), in-memory fakes, test data factories, Testcontainers for a real database.

  5. Trade-offs

    Speed and determinism versus realism. Every double is an assumption about the real world that can silently go stale.

01

The problem

2 min
Tuesday, 09:15. Partial refunds shipped yesterday at 16:00.
  1. Maya · Product manager
    All 61 refunds since last night failed. Guests are calling support.
  2. Omar · On-call SRE
    Provider returns 400: amount must be integer cents, and reason is required. We send 12.5 and no reason.
  3. You · Engineer at StayHub
    But 140 tests passed. They all called refund.
02

The big idea

4 min
A mock is a claim about the world
The big idea

Every stand-in in a test is a claim about the world: doubles make tests fast, and only checking them against reality makes them true.

Rehearsing a play with a stagehand reading the other actor's lines works well, until the stagehand reads last year's script. Rehearsal goes perfectly; opening night doesn't.

03

How it works

5 min
BookingService: one double per dependency
  1. 1
    Pricing → stub

    A stub returns canned answers (12000 cents) and doesn't care how it's called. The test is about refunds; pricing has its own tests.

  2. 2
    Repository → fake

    A fake is a small working implementation: an in-memory repository that really stores bookings. Its claims are re-checked against real Postgres.

  3. 3
    Payments → mock our adapter

    A records calls so the test can check them: refund requested once, right cents, an idempotency key. Mock our adapter, not the provider's SDK, so only one file knows the provider's format.

  4. 4
    Clock → fake time

    "48 hours before check-in" is tested by setting the time, never by waiting or computing from now.

  5. 5
    Data → fixtures and factories

    A fixture is the known world a test starts in; a builds it in code: buildBooking({ rate: "non-refundable" }), valid defaults, unique ids.

  6. 6
    Verify each claim

    A sends the adapter's real request to the provider's sandbox. Yesterday it would have failed: integer cents, reason required.

04

Watch a decision being made

4 min
Worked example

You write the refund tests, Lina reviews

Test the refund rules (full up to 48 h before check-in, 50% after, nothing for non-refundable rates) quickly, and make sure the provider's real format can never be wrong again.

  1. 1Cut at your own adapter

    BookingService depends on a PaymentsPort we own. Only the adapter knows the provider's format.

    Why: Lina: "Doubles go at boundaries you own, so hundreds of tests don't encode guesses about someone else's API."

  2. 2Build the world, control time

    A factory per test, and a fake clock set exactly 48 hours before check-in.

    ts
    vi.setSystemTime(new Date("2026-11-08T12:00:00Z"));
    const b = buildBooking({ checkIn: "2026-11-10T12:00:00Z", totalCents: 24000 });

    Why: Each test owns its data, and tests that depend on the real time fail at midnight or month ends.

  3. 3Assert outcomes, then the money call

    Check the returned refund and the stored status first; then that the adapter was asked once, with cents and an idempotency key.

    ts
    expect(result.amountCents).toBe(24000);
    expect(payments.refund).toHaveBeenCalledTimes(1);

    Why: Outcomes survive refactors. A refund sent twice costs real money, so that interaction deserves an assertion.

  4. 4Verify the mock's claim

    A contract test runs the real adapter against the sandbox: valid request accepted, missing reason rejected, decimals rejected. On adapter changes and nightly.

    Why: This is the test that would have failed yesterday.

The whole thing in one line: Mock at an adapter we own, build each test's world with factories and a fake clock, assert outcomes first, and back the mock with a contract test against the real sandbox.
05

In real life

3 min
Sandbox vs fakes

Stripe: a real test mode

  • Publicly documents a test mode with test API keys and test card numbers
  • You call the real API; nothing is charged. Slower, needs a network

Checks your assumptions against the real thing.

Google: real code, then fakes, then mocks

  • "Software Engineering at Google" recommends real implementations where practical, then fakes, mocks sparingly
  • Fakes are owned by the team that owns the real service

Fast and close to reality, if fakes are owned.

06

What goes wrong

3 min
One giant shared fixture

Tests depend on rows others change; failures depend on order.

Instead

A factory per test with unique ids; reset state between tests.

Hand-written mock responses never updated

The provider adds a required field; the mock answers the old way forever.

Instead

Back the mock with a contract test against the sandbox.

Mocking the database in repository tests

SQL, constraints and migrations never run.

Instead

Real Postgres in a container; fakes only in higher-level unit tests.

07

Trade-offs

3 min
OptionFits whenYou pay
Mocks and stubsLogic beside slow or costly dependencies; rare error pathsThey test only your assumptions
FakesRepositories and queues used by many testsCode to maintain; can drift
Real dependencies (containers, sandboxes)Code whose job is talking to the dependencySeconds per test; sandboxes can be slow or down
Wednesday, 10:00. The postmortem.
  1. Omar · On-call SRE
    Mocks lied to us. Every payment test should hit the provider's sandbox. Real truth, every push.
  2. Lina · Senior engineer, your mentor
    That's 140 tests making network calls to a sandbox that's slow some days. Back to random reds.
  3. Omar · On-call SRE
    Random reds beat 61 failed refunds.
08

Your job as tech lead

4 min

Claude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.

Decisions you own
  • Where tests may use stand-ins: at boundaries you own (payments adapter, clock, outbox), never for the SQL or constraints you're testing.
  • How every stand-in is checked against reality: the fake repository by Postgres tests, the payments by a against the sandbox.
  • How test data is created: a per test, not a shared "room_101".
Tell Claude Code
Our refund tests passed while every real refund failed with HTTP 400: the payments mock returned succeeded for any input. Plan first. In unit tests, mock only our PaymentsAdapter, and make the fake reject what the provider rejects (non-integer cents, missing reason). Add a contract test calling the provider's sandbox with a 50% refund, run in CI on adapter changes with sandbox keys from the CI secret store. No production keys. Show me rule tests for 48 hours before check-in, after, and non-refundable.
Check before you accept
  • Send the request that failed (12.5, no reason) through the new fake: it must fail like the provider did.
  • Run the sandbox contract test once and read its real status code and field names.
  • The boundary at exactly 48 hours is tested.
Red flags
  • It "fixes" the failing refund by updating the mock's expected payload to match what the code sends.
  • It puts a production payment key in test config so the contract test "works".
  • It fixes order-dependent failures by running serially or adding retries.
09

Check yourself

2 min
Q1/3Debugging
A migration adds guest_phone NOT NULL without a default. In the legacy extranet service, all 1,800 tests pass. The deploy fails because existing rows have no phone. Why didn't any test catch it?

Repository tests use vi.mock("../db") and return hand-written rows. No test starts a database.

How confident are you?

Next episode: booking renames one field that two other teams read, and Black Friday traffic is three weeks away.

Finished?

Mark it complete to update your skill map.

Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
10

Technical deep dive

A fake, and the test that keeps it honest
Worked example

You write the refund tests, Lina reviews

Test the refund rules (full refund up to 48 h before check-in, 50% after, nothing for non-refundable rates) quickly, and make sure the provider's real format can never be wrong again.

  1. 1Cut at your own adapter

    BookingService depends on a PaymentsPort we own: refund({ bookingId, amountCents, reason }). Only the adapter knows the provider's format.

    Why: Lina: "Right place. Doubles go at boundaries you own, so hundreds of tests don't encode guesses about someone else's API."

  2. 2Build each test's world with factories

    buildBooking({ rate: "non-refundable" }): valid defaults, unique ids, only the relevant detail spelled out.

    Why: Each test owns its data, so no test depends on what another created or changed.

  3. 3Control time

    Fake the clock instead of computing dates relative to now.

    ts
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-11-08T12:00:00Z"));      // 48 h before a 12:00 check-in on the 10th
    const b = buildBooking({ checkIn: "2026-11-10T12:00:00Z", totalCents: 24000 });

    Why: Tests that depend on the real time fail at midnight, at month ends, or when clocks change.

  4. 4Assert outcomes first

    Check the returned refund and the stored booking status before any interaction.

    ts
    const result = await svc.refund(b.id, { reason: "guest_request" });
    expect(result.amountCents).toBe(24000);
    expect((await repo.findById(b.id))?.status).toBe("refunded");

    Why: Outcome assertions survive refactors. Tests that check every internal call break whenever the code is reorganised.

  5. 5Mock only what costs money

    Check the adapter was asked once, with cents and an idempotency key.

    ts
    expect(payments.refund).toHaveBeenCalledTimes(1);
    expect(payments.refund).toHaveBeenCalledWith(
      expect.objectContaining({ amountCents: 24000, idempotencyKey: `refund_${b.id}` }),
    );

    Why: A refund sent twice costs real money. That interaction is behaviour, so it deserves an assertion.

  6. 6Verify the mock's claim

    A contract test runs the real adapter against the provider's sandbox: valid request accepted, missing reason rejected, decimals rejected. Runs on adapter changes and nightly.

    Why: This is the test that would have failed yesterday. It makes the mock's belief about the provider a checked fact.

The whole thing in one line: You put the mock at an adapter we own, built each test's world with factories and a fake clock, asserted outcomes first and the money-moving call second, and backed the mock with a contract test against the real sandbox.
DoubleWhat it doesUse it forWatch out
StubReturns canned answersInputs your code needs (prices, config)Hides how it's called; don't assert on it
MockRecords calls for assertionsSide effects that matter (charges, refunds, emails)Brittle if used for every call
FakeSmall working implementationRepositories, queues used by many testsCan drift; verify against the real one
SpyWraps the real thing and recordsChecking a real call happenedThe real side effect still runs
11

Interactive simulation

12

Visual diagram

Click a component

One booking service, four dependencies, four different doubles. Start the walkthrough.

13

Analogy

  • Rehearsing a playStagehand reading the other actor's linesTesting with stand-insStub: canned answers
  • Rehearsing a playDirector ticking lines off a clipboardTesting with stand-insMock: checks the calls
  • Rehearsing a playThe understudy giving a real performanceTesting with stand-insFake: simple working version
  • Rehearsing a playA camera filming the real actorTesting with stand-insSpy: records real calls
  • Rehearsing a playStage set arranged the same each timeTesting with stand-insFixture: known starting state

Where the analogy breaks: if the stagehand reads last year's script, rehearsal goes perfectly and opening night fails. Stand-ins go stale silently too, so something must check them against the real thing.

14

Check your understanding

Q1/3Recall
Match each double to what it does:
Stub
Mock
Fake
Spy
How confident are you?
15

Practice ladder

Step 1 · Trace

Name the doubles

Code Warm-up

Trace this test: for each of the four dependencies, say which kind of double it is (stub, mock, fake, spy) and whether the test asserts on it.

const pricing = { getPriceCents: () => 12000 };
const payments = { charge: vi.fn().mockResolvedValue({ id: "ch_1" }) };
const repo = new InMemoryBookingRepo();
const outbox = new LocalOutbox();
const send = vi.spyOn(outbox, "send");

const svc = new BookingService({ pricing, payments, repo, email: outbox });
const b = await svc.create({ guest: buildGuest(), roomId: "room_9", checkIn: "2026-11-10", nights: 1 });

expect(b.status).toBe("confirmed");
expect(await repo.findById(b.id)).toBeDefined();
expect(payments.charge).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledOnce();
Step 2 · Complete

Finish the payments adapter contract test

Code Practice

Lina's contract test runs the real ProviderPaymentsAdapter against the provider's sandbox. Fill in the two gaps.

const adapter = new ProviderPaymentsAdapter({ apiKey: process.env.PROVIDER_SANDBOX_KEY! });

it("sends refunds in the provider's format", async () => {
  const charge = await adapter.charge({ amountCents: 24000, card: "test_card_ok", idempotencyKey: newId() });
  const refund = await adapter.refund({ chargeId: charge.id, amountCents: 12000, reason: "guest_request", idempotencyKey: newId() });
  /* gap 1 */
});

it("refuses a refund larger than the charge", async () => {
  /* gap 2 */
});
Step 3 · Solve

Test the late-checkout fee without waiting

Code Practice

New rule: guests who check out after 11:00 hotel local time pay €20 per started hour, max €60. Hotels are in Lisbon, Amsterdam and Tokyo. A teammate's first test does:

const fee = lateFee(booking, new Date()); // "run it after 11 to see the fee"

Design the tests: how you control time, which cases you cover, and how you build the bookings.

Step 4 · Decide

Record real responses, or keep hand-written mocks?

Decision Stretch

For the email provider (used by notifications), two options:

  • A. Keep hand-written mock responses in the tests (fast, written by the team), reviewed when someone remembers.
  • B. A record/replay tool: tests run once against the provider's sandbox, record real responses into fixture files, then replay them; a nightly job re-records and fails if the shape changed. About 3 days to set up.

Last year the email provider changed its error format twice. Write a short decision: "We chose X over Y because…, accepting the risk of…".

16

Explain it yourself

A teammate says: 'Our suite is 100% mocked, so it's fast and complete.' Explain what they're getting right and what they're missing.

0 words