lecture 13 of 170 completed

A mocking policy, not a mocking habit

Route interception is powerful enough to make a suite that cannot fail. Tiering decides where it is safe.

Mock everything and your suite is fast, stable and worthless: it tests your mocks. Mock nothing and it is slow, flaky and dependent on every third party your app touches. The senior contribution is the policy between those, and it needs to be written down rather than left to per-test instinct.

Three tiers. Every test belongs to exactly one.

Tier one: fully real. Fifteen to thirty tests covering the critical journeys. Real server, real database, no interception at all. These are your contract check. When the backend changes shape, these break, which is the entire point.

Tier two: real by default, third parties stubbed. Most of the suite. Your own API is real; the payment provider, the address lookup, the analytics beacon are stubbed. You do not own those and cannot make them reliable.

Tier three: fully stubbed. States you cannot otherwise reach: server errors, empty lists, five thousand rows, slow responses that should show a spinner, permission shapes your test data lacks. These test your UI's reaction, and stubbing is the correct tool rather than a compromise.

The Playwright-specific mechanics worth knowing.

Route handlers are chainable and order-sensitive, which enables a useful pattern: stub broadly, then allow specific things through.

await page.route('**/api/**', (route) => route.continue());
await page.route('**/api/payments/**', (route) =>
  route.fulfill({ status: 200, json: { status: 'approved' } }),
);

route.fetch() lets you modify a real response, which is often better than fabricating one:

await page.route('**/api/contacts', async (route) => {
  const response = await route.fetch();
  const body = await response.json();
  body[0].status = 'archived';
  await route.fulfill({ response, json: body });
});

The shape comes from the real server, so it cannot drift. Only the one field you care about is changed. For tests that need a specific state without abandoning realism, this is the best of both, and it is underused.

HAR replay records real traffic to a file and replays it:

await page.routeFromHAR('./hars/contacts.har', { update: false });

Realistic and fast. The trap is that a HAR is a snapshot with a date on it, and nothing tells you when it went stale. If you use HAR files, re-record them on a schedule and treat that as maintenance work with an owner.

⚠️ Drift is silent, by construction.

A mock cannot detect an API change, because the mock is the thing under test. There is no failure mode that alerts you.

Two defences: keep tier one fully real so drift breaks something, and generate mock shapes from the API schema where the stakes justify it.

Make the tier visible. Tag tests so a reviewer can see which tier a change lands in:

test('checkout completes @integration', ...);
test('checkout shows a server error @stubbed', ...);

A review rule worth adopting: adding a stub to a tier-one test deserves a conversation. It usually means someone hit a flaky integration and reached for the fastest fix, which silences a genuine warning.

Remember this: three tiers, written down, tagged, with a real tier that would break on drift.

Reference: Network.