Mocking Strategies

39Mocking Strategies (deep-dive)

What you will master here

  • Mocks vs Stubs vs Fakes vs Spies vs Dummies (the test double zoo)
  • When to mock and — more importantly — when NOT to
  • Network mocking (Playwright route, MSW, nock, WireMock)
  • DB mocking (in-memory DB, Testcontainers, repository fakes)
  • Time mocking (fake timers, frozen clock)
  • Filesystem mocking
  • Contract testing as an alternative

39.1 The test double zoo (Meszaros / Fowler)

TypeWhat it isExample
DummyPassed but never usedPass null as logger
StubReturns canned answersgetUser() returns { id: 1 }
SpyRecords calls; otherwise behaves realJest jest.fn() wrapping real fn
MockPre-programmed with expectations; fails if called wrong"expect this.send() to be called once with X"
FakeWorking implementation, not for prodIn-memory DB instead of real Postgres
Colloquial usageMost engineers say "mock" for all of the above. Strictly: mock implies verification (was it called?), stub just supplies values.

39.2 When to mock — decision tree

  1. Is the dependency under your team's control?   NO → mock (external API, third-party SaaS).
  2. Is it slow or expensive?   YES → mock (LLM, payments, email).
  3. Is it flaky / has timing?   YES → mock (analytics, real-time feeds).
  4. Is the dependency itself what you're testing?   YES → don't mock.
  5. Is it a contract test?   YES → use contract testing (Pact), not free-form mocks.

39.3 Network mocking — Playwright route

// Fulfill (full mock)
await page.route('**/api/users/*', route => route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify({ id: 1, name: 'Mock' }),
}));

// Continue + modify
await page.route('**/api/orders', async route => {
  const body = JSON.parse(route.request().postData() || '{}');
  body.coupon = 'TEST50';
  await route.continue({ postData: JSON.stringify(body) });
});

// Abort
await page.route(/analytics|hotjar/, r => r.abort());

// Conditional — first request real, then mock
let count = 0;
await page.route('**/api/x', async route => {
  count++;
  if (count === 1) await route.continue();
  else await route.fulfill({ status: 500 });
});

39.4 MSW — Mock Service Worker (frontend ecosystem)

MSW intercepts fetch / XHR in the browser via a service worker (or in Node via interceptors). Same mocks used in app dev, unit tests, Storybook AND Playwright tests = no duplication.

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Alice' });
  }),
  http.post('/api/orders', async ({ request }) => {
    const body = await request.json() as any;
    return HttpResponse.json({ id: 'ord-1', ...body }, { status: 201 });
  }),
];

39.5 Other network mockers

ToolWhere it runsBest for
Playwright page.routeIn Playwright testE2E mocking, one-off scenarios
MSWBrowser SW or NodeShared with frontend dev tools
nockNode onlyAPI-only tests (no browser)
WireMockStandalone serverJVM ecosystem, multi-language tests
Prism (Stoplight)StandaloneMock from OpenAPI spec
json-serverStandalone NodeQuick REST mock from JSON file

39.6 DB mocking

StrategyToolProsCons
In-memory DBSQLite in :memory:, H2 (Java)Fast, freeBehaviour drift from prod DB
TestcontainersSpin a real Postgres in Docker per testSame DB as prodSlower startup
Repository fakeReplace data layer with in-memory MapFastest, no infraDoesn't test SQL itself
Real DB + transaction rollbackPostgres, wrap test in TX, rollbackReal behaviour, isolationSequential within DB

39.7 Time mocking

// Playwright clock API — freeze time in the browser
await page.clock.install({ time: new Date('2026-01-15T10:00:00Z') });
await page.goto('/dashboard');
/* Date.now() / new Date() in the page returns the frozen time */
await page.clock.setSystemTime(new Date('2026-01-16T10:00:00Z'));
await page.clock.fastForward(60_000);   // advance by 60s

// Jest/Vitest — for unit tests
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-15'));
// ... test setTimeout / Date behaviour ...
vi.advanceTimersByTime(5_000);
vi.useRealTimers();

39.8 Filesystem mocking

// memfs — in-memory FS for Node
import { fs as memfs } from 'memfs';
import { vi } from 'vitest';

vi.mock('fs/promises', () => memfs.promises);
// Now any code under test that reads/writes files hits memfs, not your real disk

39.9 Verifying interactions (true mocks)

// Vitest example
import { vi, expect } from 'vitest';

const send = vi.fn();
await new Notifier(send).notify('user-1', 'Hello');

expect(send).toHaveBeenCalledOnce();
expect(send).toHaveBeenCalledWith({ to: 'user-1', body: 'Hello' });

39.10 The mock-everything anti-pattern

39.11 Contract testing as the alternative

Free-form mocks decouple your test from the real API — but also from changes to the real API. If the producer updates the schema and you don't, your tests pass while prod breaks. Contract testing (Pact) publishes the contract to a broker; the producer's CI verifies it. Mocks stay in sync with the real API.

Module 39 — Mocking Q&A

Mock vs Stub vs Spy vs Fake?
Stub: returns canned values. Mock: stub + verifies it was called as expected (sets expectations). Spy: passes through real calls AND records details. Fake: working alternative implementation (in-memory DB). Most engineers say "mock" for all of them — context-dependent.
When should you NOT mock?
When the dependency IS what you're testing (testing your own UserService — don't mock UserService). When mocks would make the test pass for the wrong reasons. When real dependency is fast and stable enough to use.
Why mock external APIs in tests?
Speed (no network), determinism (no flakiness), cost (no API charges), edge-case coverage (force a 500 you can't get from real), independence (test runs without internet). Trade-off: mocks may drift from real behaviour.
How do you avoid mocks drifting from reality?
Contract testing (Pact) — the contract is verified against the real producer. Or periodically run a smoke against the real API to detect drift. Pure free-form mocks without verification rot quickly.
MSW vs Playwright route?
MSW lives in browser (service worker) or Node interceptor — shared between app dev, unit tests, Storybook, Playwright. Playwright page.route is test-only, simpler. Use MSW when you want one source of truth for mocks across the team; page.route for one-off E2E scenarios.
How do you mock a database?
Options: in-memory DB (SQLite), Testcontainers (real DB in Docker per suite), repository fake (replace data layer with a Map), or real DB with transaction rollback. Testcontainers gives the best fidelity at the cost of startup time.
How do you mock time?
Playwright's page.clock.install freezes the browser clock. Vitest/Jest's useFakeTimers + setSystemTime for unit tests. Backend: expose a test endpoint to set "now". Never use real waiting.
What's a mock-everything anti-pattern?
Mocking so much that the test only validates the mocks. Symptoms: tests pass when the real system breaks; refactors require updating dozens of mock setups. Fix: mock at the boundary (HTTP, DB), let real code run inside.
How do you verify a method was called?
Use a spy/mock framework: vi.fn() in Vitest, jest.fn() in Jest, Mockito.verify in Java. Assert on calls and arguments. Sparingly — over-asserting on internal calls makes tests brittle.
Should mocks live in tests or shared modules?
Shared. A mocks/ directory keeps consistent fake responses across tests. Avoids drift, avoids duplication, makes maintenance trivial when the schema changes.
What's contract testing in one sentence?
"Producer and consumer agree on an API contract; each tests against the contract independently — keeps mocks in sync with reality and catches schema breaks before deploy."