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.
- 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.
- 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.
- 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.
- Technology
Vitest stubs, mocks (vi.fn) and spies (vi.spyOn), in-memory fakes, test data factories, Testcontainers for a real database.
- Trade-offs
Speed and determinism versus realism. Every double is an assumption about the real world that can silently go stale.
The problem
2 min- Maya · Product managerAll 61 refunds since last night failed. Guests are calling support.
- Omar · On-call SREProvider returns 400:
amountmust be integer cents, andreasonis required. We send12.5and no reason. - You · Engineer at StayHubBut 140 tests passed. They all called refund.
The big idea
4 minEvery 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.
How it works
5 min- 1Pricing → 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.
- 2Repository → fake
A fake is a small working implementation: an in-memory repository that really stores bookings. Its claims are re-checked against real Postgres.
- 3Payments → 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.
- 4Clock → fake time
"48 hours before check-in" is tested by setting the time, never by waiting or computing from now.
- 5Data → 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. - 6Verify each claim
A sends the adapter's real request to the provider's sandbox. Yesterday it would have failed: integer cents,
reasonrequired.
Watch a decision being made
4 minYou 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.
- 1Cut at your own adapter
BookingService depends on a
PaymentsPortwe 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."
- 2Build the world, control time
A factory per test, and a fake clock set exactly 48 hours before check-in.
tsvi.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.
- 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.
tsexpect(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.
- 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.
In real life
3 minStripe: 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.
What goes wrong
3 minTests depend on rows others change; failures depend on order.
A factory per test with unique ids; reset state between tests.
The provider adds a required field; the mock answers the old way forever.
Back the mock with a contract test against the sandbox.
SQL, constraints and migrations never run.
Real Postgres in a container; fakes only in higher-level unit tests.
Trade-offs
3 min| Option | Fits when | You pay |
|---|---|---|
| Mocks and stubs | Logic beside slow or costly dependencies; rare error paths | They test only your assumptions |
| Fakes | Repositories and queues used by many tests | Code to maintain; can drift |
| Real dependencies (containers, sandboxes) | Code whose job is talking to the dependency | Seconds per test; sandboxes can be slow or down |
- Omar · On-call SREMocks lied to us. Every payment test should hit the provider's sandbox. Real truth, every push.
- Lina · Senior engineer, your mentorThat's 140 tests making network calls to a sandbox that's slow some days. Back to random reds.
- Omar · On-call SRERandom reds beat 61 failed refunds.
Your job as tech lead
4 minClaude 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.
- 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".
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.
- 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.
- 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.
Check yourself
2 minguest_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.
Next episode: booking renames one field that two other teams read, and Black Friday traffic is three weeks away.
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.
Technical deep dive
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.
- 1Cut at your own adapter
BookingService depends on a
PaymentsPortwe 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."
- 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.
- 3Control time
Fake the clock instead of computing dates relative to now.
tsvi.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.
- 4Assert outcomes first
Check the returned refund and the stored booking status before any interaction.
tsconst 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.
- 5Mock only what costs money
Check the adapter was asked once, with cents and an idempotency key.
tsexpect(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.
- 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.
| Double | What it does | Use it for | Watch out |
|---|---|---|---|
| Stub | Returns canned answers | Inputs your code needs (prices, config) | Hides how it's called; don't assert on it |
| Mock | Records calls for assertions | Side effects that matter (charges, refunds, emails) | Brittle if used for every call |
| Fake | Small working implementation | Repositories, queues used by many tests | Can drift; verify against the real one |
| Spy | Wraps the real thing and records | Checking a real call happened | The real side effect still runs |
Interactive simulation
Visual diagram
One booking service, four dependencies, four different doubles. Start the walkthrough.
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.
Check your understanding
Practice ladder
Name the doubles
Code Warm-upTrace 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();Finish the payments adapter contract test
Code PracticeLina'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 */
});Test the late-checkout fee without waiting
Code PracticeNew 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.
Record real responses, or keep hand-written mocks?
Decision StretchFor 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…".
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.