Tech Lead NotesRohit’s interview study guide
Chapter 06

Testing Strategy

What each kind of test catches and costs, the pyramid and the trophy and why they mostly agree, what coverage does and doesn't tell you, how to find and fix flaky tests, and how to get a legacy codebase under test. The test examples are real node:test suites that run on Node 26.

5 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Unit, integration, contract and end-to-end tests

Added

Tests differ in how much of the real system they include. A unit test runs one function or class with everything else absent or faked. An integration test runs several real pieces together: your route handler, your validation, your SQL against a real database. A contract test checks that two separately deployed things (the web UI and the API, the API and a device) agree on the shape of what they exchange, without running both at once. An end-to-end (E2E) test drives the whole deployed system the way a user does: a browser clicking through the web UI, against a running API and database.

The more real system a test includes, the more kinds of bug it can catch, and the more it costs: it's slower, it needs more setup, it fails for more reasons that aren't bugs (a slow container, a network blip), and when it fails it points less precisely at the cause. A failing unit test says "this function returns the wrong thing for this input"; a failing E2E test says "something between the browser and the database is wrong".

The mental model: checking a car. Unit tests are each part on a workbench: the brake caliper squeezes with the right force. Integration tests are the engine on a test stand with real fuel lines and real sensors. A contract test is the agreed drawing of the bolt pattern between the wheel supplier and the axle supplier: each checks its own part against the drawing, and they never have to meet. The E2E test is the test drive. You need a few test drives, but you wouldn't find a faulty caliper by driving around the block a thousand times.

LevelExercisesCatchesMissesSpeedExample for a web UI + device API
Unitone function or class, collaborators fakedlogic errors, edge cases, boundarieswiring, SQL, serialisation, configmillisecondsvalidateDeviceName, a reducer, a date formatter
Integrationreal pieces together: HTTP + handler + database, or a UI component with its real childrenwrong SQL, missing migrations, status codes, JSON shape, middleware orderother services, the real browser, deployment configtens to hundreds of msPATCH /api/v1/devices/:id against PostgreSQL in a container
Contracteach side against a recorded agreementbreaking API changes before they're deployedbehaviour behind the fieldsmilliseconds"the device page reads firmware.version as a string"
End-to-endthe deployed system through its real UIbroken journeys, environment and config errors, things nobody thought to testprecise cause; anything off the happy path (too slow to test it all)seconds per testsign in, open a room, rename its device, see the new name

Here's one feature, renaming a device, tested at the unit level and the integration level with Node's built-in test runner:

test-levels.mjs
// One feature, "rename a device", tested at two levels with node:test. The file runs its own tests through
// node:test's run() and prints a short report (no timings), so the output is the same on every run.
import { test, run } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";

export function validateDeviceName(name) {
  if (typeof name !== "string") return "name must be a string";
  const trimmed = name.trim();
  if (trimmed.length < 1 || trimmed.length > 32) return "name must be 1-32 characters";
  if (!/^[\p{L}\p{N} ._-]+$/u.test(trimmed)) return "name has characters a device can't display";
  return null;
}
export function createApp(devices) {
  return createServer(async (req, res) => {
    const match = req.method === "PATCH" && req.url.match(/^\/api\/v1\/devices\/([\w-]+)$/);
    const send = (status, body) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
    if (!match) return send(404, { title: "Not found" });
    const device = devices.get(match[1]);
    if (!device) return send(404, { title: "No such device" });
    let body = "";
    for await (const chunk of req) body += chunk;
    const { name } = JSON.parse(body || "{}");
    const problem = validateDeviceName(name);
    if (problem) return send(422, { title: "Invalid name", detail: problem });
    device.name = name.trim();
    send(200, device);
  });
}

if (process.env.NODE_TEST_CONTEXT) {
  test("unit: accepts a normal room name", () => assert.equal(validateDeviceName("  Boardroom 4 "), null));
  test("unit: rejects an empty name", () => assert.equal(validateDeviceName("   "), "name must be 1-32 characters"));
  test("unit: rejects emoji the panel can't render", () => assert.match(validateDeviceName("War room 🚀"), /characters/));

  test("integration: PATCH renames the device and returns it", async (t) => {
    const devices = new Map([["dev-1", { id: "dev-1", name: "Room 1", model: "VC-200" }]]);
    const server = createApp(devices).listen(0, "127.0.0.1");
    t.after(() => server.close());
    await new Promise((resolve) => server.once("listening", resolve));
    const url = `http://127.0.0.1:${server.address().port}/api/v1/devices/dev-1`;
    const res = await fetch(url, { method: "PATCH", body: JSON.stringify({ name: " Boardroom 4 " }) });
    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), { id: "dev-1", name: "Boardroom 4", model: "VC-200" });
    assert.equal(devices.get("dev-1").name, "Boardroom 4");
  });
  test("integration: invalid name is a 422 problem and nothing changes", async (t) => {
    const devices = new Map([["dev-1", { id: "dev-1", name: "Room 1" }]]);
    const server = createApp(devices).listen(0, "127.0.0.1");
    t.after(() => server.close());
    await new Promise((resolve) => server.once("listening", resolve));
    const res = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/devices/dev-1`, { method: "PATCH", body: "{}" });
    assert.equal(res.status, 422);
    assert.equal((await res.json()).detail, "name must be a string");
    assert.equal(devices.get("dev-1").name, "Room 1");
  });
} else {
  for await (const { type, data } of run({ files: [import.meta.filename] })) {
    if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
    if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n  ${data.details.error.cause?.message}`);
  }
}
Result
✔ unit: accepts a normal room name
✔ unit: rejects an empty name
✔ unit: rejects emoji the panel can't render
✔ integration: PATCH renames the device and returns it
✔ integration: invalid name is a 422 problem and nothing changes
What’s happening
  1. How the file runs. Run directly, NODE_TEST_CONTEXT isn't set, so the else branch calls node:test's run() on this same file. run() starts a child process for it in test mode, where Node sets NODE_TEST_CONTEXT, so the child takes the first branch and registers the tests. The parent prints one line per top-level result and no timings, which is what makes the output identical on every run. In a real project you'd keep the tests in devices.test.mjs and run node --test.
  2. Unit tests call validateDeviceName directly: no server, no network, microseconds each. They pin the rules (trim, 1–32 characters, only letters, digits, spaces and ._-) including the edge cases that are cheap to list here and expensive to reach through a browser: an all-spaces name, an emoji the device's panel can't render.
  3. Integration tests start the real HTTP server on port 0 of 127.0.0.1 (the OS picks a free port, so parallel test files never collide), wait for its listening event before reading server.address().port, send a real PATCH with fetch to that same address, and check three things: the status code, the JSON body, and the state behind the API (devices.get("dev-1").name). t.after(() => server.close()) closes the server even if an assertion fails, so a failing test can't hang the run. The explicit host matters: a bare listen(0) binds ::, and another program on the machine can already hold the same port number on 127.0.0.1, so a request to 127.0.0.1 (or to localhost, if it resolves there) could reach the wrong server.
  4. The second integration test sends {} and expects a 422 with a problem body, and checks that nothing changed. That's the kind of bug only this level catches: a handler that validates but writes first, or returns 200 with an error inside.
  5. What neither level covers: the real database (the Map stands in for it; in CI the integration job uses PostgreSQL from a service container, see A GitHub Actions pipeline for a web UI and API), the web UI, and whether the deployed services agree. That's what contract and E2E tests are for.
AdvancedContract tests in miniature

When the web UI and the API are deployed separately (or the API and the software on thousands of devices), the dangerous change is one that's fine for the provider and breaks a consumer. E2E tests catch it late and expensively; contract tests catch it on the provider's own pipeline. Pact, the best-known tool, describes contract testing as "a technique for testing an integration point by checking each application in isolation", and is consumer-driven: the contract is generated by running the consumer's tests, so it lists only what consumers actually use (docs.pact.io, checked 27 October 2026). This model shows the idea without the tool:

contract-check.mjs
// Consumer-driven contracts in miniature: each consumer records the fields it actually reads from an API response,
// and the provider checks every candidate build against all recorded contracts before it deploys.
const contracts = {
  "web UI device page": {
    request: "GET /api/v1/devices/dev-1",
    expects: { id: "string", name: "string", status: ["online", "offline"], "firmware.version": "string" },
  },
  "room-booking service": {
    request: "GET /api/v1/devices/dev-1",
    expects: { id: "string", status: ["online", "offline"] },
  },
};
const get = (obj, path) => path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
function verify(contract, response) {
  const problems = [];
  for (const [path, rule] of Object.entries(contract.expects)) {
    const value = get(response, path);
    if (value === undefined) problems.push(`missing ${path}`);
    else if (Array.isArray(rule) && !rule.includes(value)) problems.push(`${path} is "${value}", expected one of ${rule.join("|")}`);
    else if (!Array.isArray(rule) && typeof value !== rule) problems.push(`${path} is a ${typeof value}, expected ${rule}`);
  }
  return problems;
}
const providerBuilds = {
  "api v1.8 (current)": { id: "dev-1", name: "Boardroom 4", status: "online", firmware: { version: "10.2.1" } },
  "api v1.9 adds lastSeen": { id: "dev-1", name: "Boardroom 4", status: "online", firmware: { version: "10.2.1" }, lastSeen: "2026-10-27T09:00:00Z" },
  "api v2.0 flattens firmware": { id: "dev-1", name: "Boardroom 4", status: "online", firmwareVersion: "10.2.1" },
  "api v2.1 adds status 'updating'": { id: "dev-1", name: "Boardroom 4", status: "updating", firmware: { version: "10.3.0" } },
};
for (const [build, response] of Object.entries(providerBuilds)) {
  const results = Object.entries(contracts).map(([consumer, c]) => [consumer, verify(c, response)]);
  const ok = results.every(([, p]) => p.length === 0);
  console.log(`${build}: ${ok ? "safe to deploy" : "BLOCKED"}`);
  for (const [consumer, p] of results) if (p.length) console.log(`  breaks ${consumer}: ${p.join("; ")}`);
}
Result
api v1.8 (current): safe to deploy
api v1.9 adds lastSeen: safe to deploy
api v2.0 flattens firmware: BLOCKED
  breaks web UI device page: missing firmware.version
api v2.1 adds status 'updating': BLOCKED
  breaks web UI device page: status is "updating", expected one of online|offline
  breaks room-booking service: status is "updating", expected one of online|offline
What’s happening
  1. Each consumer records the fields it reads and what it expects of them. The web UI's device page reads four fields, including firmware.version; the room-booking service only reads id and status. In Pact these come from the consumers' own tests, so they can't drift from what the code does.
  2. verify checks one provider response against one contract: a missing field, an unexpected enum value, or a wrong type is a problem. Extra fields are ignored, because no consumer reads them.
  3. v1.9 adds lastSeen: safe for everyone. Adding fields is backwards compatible, and the contracts prove it rather than leaving it to hope.
  4. v2.0 flattens firmware.version into firmwareVersion: blocked, and the report names exactly who breaks (the device page) and who doesn't (room booking). The provider team can now add the new field alongside the old one, migrate the UI, then remove the old field: expand and contract.
  5. v2.1 adds the status "updating": blocked for both consumers, even though no field was removed. A new enum value is a breaking change for a consumer that switches on the value, the most commonly missed kind of API break. In Pact's workflow the provider runs this verification in CI, and a broker answers "can I deploy this version?" against the versions of the consumers actually in production.
AdvancedStubs, spies, fakes and mocks

When a unit or integration test replaces a collaborator, the replacement is a test double, and the kind matters, because it decides what the test can prove. The vocabulary is Gerard Meszaros's (xUnit Test Patterns, 2007), popularised by Fowler's article "Mocks Aren't Stubs": a dummy is passed but never used; a stub returns canned answers; a spy is a stub that also records how it was called, for the test to check afterwards; a fake is a working but simplified implementation (an in-memory store); a mock is pre-programmed with the calls it expects and verifies them itself. node:test has mock.fn() and mock.method() built in; despite the name, by these definitions what mock.fn() gives you is a spy:

test-doubles.mjs
// Test doubles in one node:test suite: a fake works, a stub answers, a spy records and the test checks its calls afterwards.
import { test, mock, run } from "node:test";
import assert from "node:assert/strict";

// Code under test: tell every online device on an old firmware version that an update is available.
export async function announceUpdate({ devices, getLatestVersion, push }) {
  const latest = await getLatestVersion();
  const outdated = (await devices.list()).filter((d) => d.online && d.firmware !== latest);
  for (const d of outdated) await push(d.id, { type: "update-available", version: latest });
  return outdated.length;
}
class FakeDeviceStore { // a real, working implementation, just in memory
  #rows = new Map();
  async add(device) { this.#rows.set(device.id, { ...device }); }
  async list() { return [...this.#rows.values()]; }
}

if (process.env.NODE_TEST_CONTEXT) {
  test("pushes only to online devices on old firmware", async () => {
    const devices = new FakeDeviceStore(); // fake
    await devices.add({ id: "dev-1", online: true, firmware: "10.2.0" });
    await devices.add({ id: "dev-2", online: false, firmware: "10.2.0" });
    await devices.add({ id: "dev-3", online: true, firmware: "10.3.0" });
    const getLatestVersion = async () => "10.3.0"; // stub: canned answer, never checked
    const push = mock.fn(async () => {}); // spy: records every call
    assert.equal(await announceUpdate({ devices, getLatestVersion, push }), 1);
    assert.equal(push.mock.callCount(), 1);
    assert.deepEqual(push.mock.calls[0].arguments, ["dev-1", { type: "update-available", version: "10.3.0" }]);
  });
  test("a failing push stops the run (current behaviour, pinned)", async () => {
    const devices = new FakeDeviceStore();
    await devices.add({ id: "dev-1", online: true, firmware: "10.2.0" });
    await devices.add({ id: "dev-4", online: true, firmware: "10.1.0" });
    const push = mock.fn(async (id) => { if (id === "dev-1") throw new Error("push service 503"); });
    await assert.rejects(announceUpdate({ devices, getLatestVersion: async () => "10.3.0", push }), /503/);
    assert.equal(push.mock.callCount(), 1); // dev-4 was never told: a finding to raise, not to hide
  });
} else {
  for await (const { type, data } of run({ files: [import.meta.filename] })) {
    if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
    if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n  ${data.details.error.cause?.message}`);
  }
}
Result
✔ pushes only to online devices on old firmware
✔ a failing push stops the run (current behaviour, pinned)
What’s happening
  1. announceUpdate needs three collaborators: a device store, a way to get the latest firmware version, and a push channel. Each is passed in, so the test can choose what stands in for it.
  2. FakeDeviceStore is a fake: it really stores and lists devices, in a Map. Fakes keep tests readable (you set up data the way production would) and survive refactors, because they implement the real interface rather than a script of calls.
  3. getLatestVersion is a stub: it always answers "10.3.0" and the test never asks how it was called. push is a spy made with mock.fn(): push.mock.callCount() and push.mock.calls[0].arguments show it was called once, for dev-1 only (dev-2 is offline, dev-3 is already current). Checking the calls afterwards is behaviour verification with a spy; a strict Meszaros mock would have been given those expectations up front and failed by itself on any other call. In everyday speech people call both "mocks", so say which you mean.
  4. The second test makes the spy throw for dev-1. The function rejects with the 503 and dev-4 is never told about the update. The test pins that behaviour, and it's also a finding: one failed push silently skips every device after it. That's worth raising, and it's exactly the kind of thing a fake push channel surfaces and a real one rarely does in a test.
  5. The rule of thumb from Mocking everything: use doubles for the edges you don't own (push services, the clock, other teams' APIs), fakes or the real thing for what you do own, and assert on outcomes before calls.
AdvancedWhat end-to-end tests are for

E2E tests are the only ones that prove the deployed pieces work together through the real UI, so keep a small set and choose them deliberately:

  • The critical journeys, the ones that would page someone: sign in; find a device and change a setting; start a meeting from a room; the admin flow a client's IT team uses daily. Ten to thirty tests, not three hundred.
  • Not the edge cases. "Name with 33 characters is rejected" belongs in a unit test; the E2E suite checks that a rejection is shown once.
  • Run them against a production-like environment with seeded data, in parallel shards, with retries reported (Playwright labels a test that failed and then passed on retry as "flaky", rather than hiding it).
  • Device platforms add a level: tests against real or emulated hardware ("hardware in the loop") for the parts that depend on the device. They're slow and scarce, so they run on a schedule or before a release, and the API contract tests keep everyday changes from needing them.
AdvancedPitfall: mocking everything
Which level catches it?

For each bug, which is the cheapest kind of test that would catch it? (a) stepVolume(95, 10) returns 105. (b) The API returns firmware_version but the web UI reads firmwareVersion. (c) A migration wasn't added for a new column, so PATCH /devices/:id fails with a SQL error. (d) The production build points the web UI at the staging API URL.

Show answer

(a) unit, (b) contract, (c) integration, (d) end-to-end (or a smoke test after deploy).

What’s happening
  1. (a) is pure logic with a boundary: a unit test with the input (95, 10) catches it in microseconds and names the function.
  2. (b) is a disagreement between two separately built pieces. Each side's own tests pass, because each is self-consistent. A consumer contract ("the device page reads firmwareVersion") verified against the provider's response catches it in the API's pipeline. An E2E test would catch it too, later and slower.
  3. (c) needs the real schema: a unit test with a mocked repository passes. An integration test that runs the migrations and then calls the endpoint against PostgreSQL fails with the real SQL error.
  4. (d) is configuration of the deployed artefact. No test inside the codebase sees it; a post-deploy smoke test or an E2E test against production-like config does. That's also why "build once, inject config at deploy time" matters (see Environments, approvals and deployment strategies).

#Test pyramid vs testing trophy

Added

The test pyramid is the classic picture of a healthy suite: a wide base of fast unit tests, a smaller layer of service or integration tests, and a narrow top of slow UI tests. Mike Cohn described it as the "Test Automation Pyramid" in Succeeding with Agile (2009), and Martin Fowler's "TestPyramid" bliki entry (1 May 2012) made it famous. Fowler's argument is about cost: UI-driven E2E tests are brittle, expensive to write and slow to run, so you want many more unit tests than GUI tests. The anti-pattern is the ice-cream cone: a suite that's mostly manual or UI tests on top of a few unit tests, often grown by record-and-playback tools.

The testing trophy is Kent C. Dodds's answer for JavaScript applications. It started from Guillermo Rauch's December 2016 tweet "Write tests. Not too many. Mostly integration.", became the trophy in 2018, and is explained in "The Testing Trophy and Testing Classifications" (3 June 2021). From the bottom: a static layer (types and linters), a thin layer of unit tests, a thick layer of integration tests, and a small cap of E2E. Dodds frames it as return on investment: the return is confidence that the software works, the investment is time, and integration tests give the most confidence per minute for a typical web app.

The mental model: both are budgets, not laws. They answer "where should most of our testing effort go?" for a particular kind of code. The pyramid assumes unit tests are cheap and E2E is expensive, which is still true. The trophy adds that in a UI-heavy app many "units" are thin glue whose bugs only show up when pieces meet, and that static types catch a whole class of bugs for free. Fowler's 2021 article, written about shapes like the trophy and Spotify's "honeycomb", argues the disagreement is mostly about words: what their advocates call integration tests are often what he calls sociable unit tests, fast, in-process tests with real collaborators. What every version agrees on: few, well-chosen E2E tests, and most of the suite fast and reliable.

Test pyramid Cohn 2009, Fowler 2012 E2E integration unit Testing trophy Dodds 2018 E2E integration unit static: types, lint Ice-cream cone the anti-pattern manual testing E2E / UI integration unit width = share of the testing effort
Three shapes for the same question: where does most of the testing effort go?

Whatever the shape, the levels also differ in when they run, which is part of the strategy:

WhenWhat runsBudget
On save, in the editortypes, lint, the unit tests for the file (watch mode)seconds
Pre-commit hookformat, lint on staged filesunder 10 seconds, or people skip it
Every push to a PReverything that blocks merging: static, unit, integration, contract, critical E2Eunder 10 minutes
Merge queuethe same checks on the combined resultsame
Nightlythe full browser matrix, quarantined tests repeated, mutation testing on critical modulesan hour is fine
Before a device releasehardware-in-the-loop and soak tests on real unitsdays, scheduled

The shapes have a cost you can calculate. This model compares what three portfolios for the same app cost on every CI run, in time and in red builds caused by flakes:

suite-economics.mjs
// Three test portfolios for the same web UI + API, compared on what they cost on every CI run.
// Per-test runtimes and flake rates are illustrative assumptions (a flake = a failure with no code change).
const kinds = {
  unit: { seconds: 0.005, flakeRate: 0.000001 },
  integration: { seconds: 0.25, flakeRate: 0.00002 }, // real HTTP + a real database in a container
  e2e: { seconds: 15, flakeRate: 0.001 }, // a browser driving the deployed UI
};
const portfolios = {
  "pyramid (Cohn/Fowler)": { unit: 2000, integration: 200, e2e: 20 },
  "trophy (Dodds)": { unit: 400, integration: 900, e2e: 20 },
  "ice-cream cone": { unit: 100, integration: 50, e2e: 300 },
};
const WORKERS = 4; // tests split across 4 parallel workers or shards
console.log("portfolio               tests  minutes on 4 workers  runs red with no bug");
for (const [name, mix] of Object.entries(portfolios)) {
  let tests = 0, seconds = 0, allGreen = 1;
  for (const [kind, count] of Object.entries(mix)) {
    tests += count;
    seconds += count * kinds[kind].seconds;
    allGreen *= (1 - kinds[kind].flakeRate) ** count; // every test must avoid flaking for the run to be green
  }
  const minutes = seconds / WORKERS / 60;
  console.log(`${name.padEnd(22)} ${String(tests).padStart(6)}  ${minutes.toFixed(1).padStart(20)}  ${((1 - allGreen) * 100).toFixed(1).padStart(19)}%`);
}
Result
portfolio               tests  minutes on 4 workers  runs red with no bug
pyramid (Cohn/Fowler)    2220                   1.5                  2.6%
trophy (Dodds)           1320                   2.2                  3.8%
ice-cream cone            450                  18.8                 26.0%
What’s happening
  1. The per-test numbers are assumptions for illustration: a unit test takes 5 ms and almost never flakes, an integration test (real HTTP and a database in a container) takes 250 ms and flakes once in 50,000 runs, an E2E test takes 15 s and flakes once in 1,000. The shape of the result matters more than the exact values.
  2. Time is the sum of count × seconds, divided across four workers. A run is green only if every test avoids flaking, so the chance of a red build with no bug is 1 − Π(1 − p)ⁿ over all tests: it compounds with every flaky-ish test you add.
  3. The pyramid (2,000 unit, 200 integration, 20 E2E) runs in 1.5 minutes and goes red without a bug 2.6% of the time, about one run in 39. The trophy (400 unit, 900 integration, 20 E2E) takes 2.2 minutes and 3.8%. They're close: the 20 E2E tests dominate both the time and the flake rate.
  4. The ice-cream cone (300 E2E tests) takes 18.8 minutes on four workers and a quarter of its runs fail for no reason. That's the suite where people stop reading failures, which costs more than the minutes.
  5. The lesson isn't "pyramid beats trophy" or the reverse. It's that the number of slow, flaky tests sets the cost of the whole suite, so the top of either shape must stay small, and the rest is a judgement about where your bugs actually come from.
AdvancedChoosing a shape for a web UI and API

What I'd propose for the kind of codebase this site is about (a React or Angular admin UI, a Node REST API, devices in the field):

  • Static base: TypeScript in strict mode, ESLint, and generated API types from the OpenAPI spec, so the UI and API can't disagree about field names at compile time.
  • Unit tests for code that is logic: validators, formatters, permission rules, reducers and state machines (device states: online, offline, updating), retry and backoff maths.
  • Integration tests as the bulk on the API side: real routes, real middleware, a real PostgreSQL in a container, one test per behaviour (status codes, validation, authorisation, pagination). On the UI side, component tests that render a screen with its real child components and a mocked network layer (Testing Library for React, TestBed for Angular), and assert what the user sees.
  • Contract tests between the UI and the API, and between the API and the device software, because they ship on different schedules.
  • E2E: a dozen or two critical journeys in Playwright, sharded, run on every PR, with flaky ones quarantined.

The shape that results is closer to a trophy on the UI and closer to a pyramid on the API. That's fine. Say so in the interview, and say why.

What happens to the red builds?

A team's suite has 2,000 unit tests and 20 E2E tests (the pyramid row above). A new initiative adds 80 more E2E tests "for coverage", with the same per-test flake rate (0.1%). Roughly how often will a run now go red with no bug, and what happens to run time?

Show answer

About 10% of runs go red for nothing, nearly four times as often as before (2.6%), and the E2E part of the run grows fivefold.

What’s happening
  1. With 100 E2E tests at p = 0.001, the chance that none of them flakes is 0.999¹⁰⁰ ≈ 0.905. The integration and unit tests multiply that by 0.994 (0.99998²⁰⁰ × 0.999999²⁰⁰⁰), so the run is green about 89.9% of the time.
  2. So about 10.1% of runs are red with no bug: roughly one in ten, against one in 39 before (2.6%).
  3. E2E time goes from 20 × 15 s = 5 minutes to 100 × 15 s = 25 minutes of test time; on four workers the run grows from about 1.5 to about 6.5 minutes.
  4. A better use of the effort: find what the new E2E tests were meant to protect and cover it with integration or contract tests, keeping only the ones that guard a critical journey.

#Coverage: useful signal or vanity metric

Added

Code coverage measures which code ran while the tests ran: lines, statements, functions, or branches (each side of every if, &&, ?:). It says nothing about whether the tests checked anything. A test that calls every function and asserts nothing has 100% line coverage.

So coverage is good at one thing: finding code that no test touches at all. Martin Fowler's "TestCoverage" bliki entry (17 April 2012) makes this point: it's a useful tool for finding untested parts of a codebase and of little use as a numeric statement of how good your tests are. Thoughtful testing tends to land in the high eighties or nineties, he says, and he'd be suspicious of 100%, which often means tests written to satisfy the number. Google's testing blog ("Code Coverage Best Practices", 7 August 2020) offers rough guidelines of 60% as "acceptable", 75% as "commendable" and 90% as "exemplary", warns against top-down mandates, and says per-commit coverage goals (99%, with 90% as a good lower threshold) are more useful than chasing the project-wide figure.

KindCountsBlind spot
Linelines with any executed codea line with if (x) return a; counts as covered when only the condition ran
Statementindividual statements, even several on one linewhich way each decision went
Brancheach outcome of every if, ?:, &&, ||, switch casewhether the result was checked
Functionfunctions called at least onceeverything inside them
Mutation scoredeliberate bugs the tests catchslow to compute; equivalent mutants need a human

The mental model: coverage is the map of where the inspector walked, not a record of what they inspected. If a room isn't on the map, you know nobody looked. If it is, you only know someone passed through. Mutation testing is the stronger check: change the code on purpose (flip > to >=, + to -) and see whether any test notices. A change no test notices is a gap, whatever the coverage says.

coverage-vs-mutants.mjs
// Two test suites for the same function. Both reach 100% line coverage (measured by node:test's own coverage).
// Then a tiny mutation tester changes one operator at a time and asks: does the suite notice?
import { run } from "node:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const source = `export function stepVolume(current, delta) {
  const next = current + delta;
  if (next > 100) return 100;
  if (next < 0) return 0;
  return next;
}
`;
const suites = {
  weak: `assert.equal(typeof stepVolume(50, 10), "number"); stepVolume(95, 10); stepVolume(5, -10);`,
  strong: `assert.equal(stepVolume(50, 10), 60); assert.equal(stepVolume(95, 6), 100); assert.equal(stepVolume(5, -6), 0);`,
};
const mutants = [
  ["+ delta", "- delta"], ["return 100", "return 99"], ["return 0", "return 1"],
  ["> 100", "> 101"], ["< 0", "< -1"], ["> 100", ">= 100"],
];
const dir = mkdtempSync(join(tmpdir(), "mut-"));
const write = (name, text) => (writeFileSync(join(dir, name), text), join(dir, name));

async function lineCoverage(suite) {
  write("volume.mjs", source);
  const test = write("volume.test.mjs", `import { test } from "node:test"; import assert from "node:assert/strict";
import { stepVolume } from "./volume.mjs"; test("suite", () => { ${suite} });`);
  for await (const { type, data } of run({ files: [test], coverage: true, coverageIncludeGlobs: ["**/volume.mjs"] }))
    if (type === "test:coverage") return data.summary.files[0].coveredLinePercent;
}
let mutantFiles = 0;
async function survivors(suite) {
  const alive = [];
  for (const [from, to] of mutants) {
    const file = write(`mutant${mutantFiles++}.mjs`, source.replace(from, to));
    const { stepVolume } = await import(file);
    const assert = (await import("node:assert/strict")).default;
    try { new Function("assert", "stepVolume", suite)(assert, stepVolume); alive.push(`${from} → ${to}`); } catch {}
  }
  return alive;
}
for (const [name, suite] of Object.entries(suites)) {
  const alive = await survivors(suite);
  console.log(`${name.padEnd(6)} line coverage ${await lineCoverage(suite)}%, mutants killed ${mutants.length - alive.length}/${mutants.length}`);
  for (const m of alive) console.log(`         survived: ${m}`);
}
rmSync(dir, { recursive: true, force: true });
Result
weak   line coverage 100%, mutants killed 0/6
         survived: + delta → - delta
         survived: return 100 → return 99
         survived: return 0 → return 1
         survived: > 100 → > 101
         survived: < 0 → < -1
         survived: > 100 → >= 100
strong line coverage 100%, mutants killed 5/6
         survived: > 100 → >= 100
What’s happening
  1. stepVolume clamps a device's volume to 0–100. The weak suite calls it three times, through every line, but its only assertion is that the result is a number. The strong suite asserts exact results, including the boundaries: 95 + 6 must clamp to 100 and 5 − 6 to 0.
  2. Coverage comes from Node's own test runner: run({ coverage: true }) on a test file that imports the module, restricted to volume.mjs with coverageIncludeGlobs. Both suites report 100% line coverage. (Node 26 still marks test coverage as experimental.)
  3. The mutation tester writes six altered copies of the module and runs each suite against each. The weak suite kills none: every mutant still returns a number. That's 100% coverage with a suite that would let you ship current - delta.
  4. The strong suite kills five. The one survivor, > 100 → >= 100, is an equivalent mutant: when next is exactly 100, both versions return 100, so no test can tell them apart. Mutation tools report these too, and part of reading the score is recognising them.
  5. > 100 → > 101 is killed only because of the 95 + 6 test (101 must clamp to 100). Boundary values are what mutation testing pushes you to write, and they're exactly the inputs where clamps and limits fail in production.
AdvancedUsing coverage well
  • Look at the uncovered lines in the diff, not the project percentage. Most tools (Codecov, Coveralls, SonarQube, or lcov in a PR comment) can show coverage for changed lines only; a new branch with no test is the useful signal in review. That's Google's per-commit idea.
  • Set a floor, not a target. A threshold just under the current figure stops decay without inviting games. The CI chapter shows a Vitest threshold config and the real failure it printed (A coverage floor), including the case where lines were 100% while branches were 50%: count branches, not just lines.
  • Hold critical code to a higher bar with per-folder thresholds (authentication, billing, the device command path), and leave generated code and thin glue out of the figure honestly, with a comment saying why.
  • Run mutation testing occasionally on the code that matters. Stryker is the common tool for JavaScript and TypeScript; it's slow (it reruns tests for every mutant), so a nightly or weekly run on a few critical modules is typical.
  • Coverage of E2E and integration tests counts too, with a caveat. Google's post says unit-test coverage is "only a piece of the puzzle" and integration and system test coverage matters too, but warns that some of that coverage is incidental rather than deliberate: a route that an integration test happened to run isn't necessarily a route whose result anything checked.
AdvancedDiff coverage on a real pull request

The per-commit idea is easy to compute: take the lines the PR adds from git diff, and look up each one in the coverage report. This model does it with real git and Node's coverage:

diff-coverage.mjs
// Diff coverage: of the lines a pull request ADDS, how many ran under the tests? Real git for the diff, Node's
// test runner for the coverage. The file's overall figure can look fine while the new code is untested.
import { run } from "node:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "diffcov-"));
const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "Dev",
  GIT_AUTHOR_EMAIL: "dev@example.com", GIT_COMMITTER_NAME: "Dev", GIT_COMMITTER_EMAIL: "dev@example.com",
  GIT_AUTHOR_DATE: "2026-10-20T10:00:00Z", GIT_COMMITTER_DATE: "2026-10-20T10:00:00Z" };
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", env });
const base = [
  "export function label(device) {", "  const name = device.name.trim();", "  if (!device.online) return `${name} (offline)`;",
  "  return name;", "}",
  "export function byName(a, b) {", "  return a.name.localeCompare(b.name);", "}",
  "export function firmwareLabel(version) {", "  const [major, minor] = version.split(\".\");", "  return `v${major}.${minor}`;", "}",
];
const added = [ // the pull request: a new status for devices that are installing firmware
  "export function badge(device) {", "  if (device.status === \"updating\") {", "    const pct = Math.round(device.progress * 100);",
  "    return `Updating ${pct}%`;", "  }", "  return device.online ? \"Online\" : \"Offline\";", "}",
];
writeFileSync(join(dir, "devices.mjs"), base.join("\n") + "\n");
writeFileSync(join(dir, "devices.test.mjs"), `import { test } from "node:test"; import assert from "node:assert/strict";
import { label, byName, firmwareLabel, badge } from "./devices.mjs";
test("label", () => { assert.equal(label({ name: " Room 1 ", online: false }), "Room 1 (offline)"); assert.equal(label({ name: "Room 2", online: true }), "Room 2"); });
test("byName", () => assert.equal([{ name: "B" }, { name: "A" }].sort(byName)[0].name, "A"));
test("firmwareLabel", () => assert.equal(firmwareLabel("10.2.1"), "v10.2"));
test("badge", () => assert.equal(badge({ online: true, status: "idle" }), "Online"));`);
git("init", "-q", "-b", "main"); git("add", "."); git("commit", "-qm", "Device labels");
writeFileSync(join(dir, "devices.mjs"), [...base, ...added].join("\n") + "\n");
git("commit", "-qam", "Add device badge");

const newLines = new Set(); // line numbers added by the last commit, from the unified diff's hunk headers
for (const [, start, count = "1"] of git("diff", "-U0", "HEAD~1", "HEAD", "--", "devices.mjs").matchAll(/^@@ -\S+ \+(\d+)(?:,(\d+))? @@/gm))
  for (let i = 0; i < Number(count); i++) newLines.add(Number(start) + i);
let lines = [];
for await (const { type, data } of run({ files: [join(dir, "devices.test.mjs")], coverage: true, coverageIncludeGlobs: ["**/devices.mjs"] }))
  if (type === "test:coverage") lines = data.summary.files[0].lines; // [{ line, count }] for executable lines
const source = [...base, ...added];
const pct = (xs) => `${Math.round((xs.filter((l) => l.count > 0).length / xs.length) * 100)}%`;
const changed = lines.filter((l) => newLines.has(l.line));
console.log(`whole file: ${pct(lines)} of ${lines.length} lines covered`);
console.log(`added by the PR: ${pct(changed)} of ${changed.length} lines covered`);
for (const l of changed.filter((l) => l.count === 0)) console.log(`  not run: line ${l.line}: ${source[l.line - 1].trim()}`);
rmSync(dir, { recursive: true, force: true });
Result
whole file: 84% of 19 lines covered
added by the PR: 57% of 7 lines covered
  not run: line 15: const pct = Math.round(device.progress * 100);
  not run: line 16: return `Updating ${pct}%`;
  not run: line 17: }
What’s happening
  1. The base commit has three small functions and tests for all of them. The PR adds badge(), with a new branch for devices that are installing firmware, and one test for the plain "Online" case.
  2. git diff -U0 HEAD~1 HEAD prints hunk headers like @@ -12,0 +13,7 @@: the PR added 7 lines starting at line 13. -U0 (no context lines) makes the headers list exactly the added lines.
  3. Node's coverage (test:coverage event) gives a hit count per line of devices.mjs. Over the whole file, 16 of 19 lines ran: 84%, a figure that would pass most project-wide gates.
  4. Of the 7 lines the PR added, only 4 ran: 57%. The report names the three that never ran, the whole "updating" branch, the new feature the PR exists for. That's the line a reviewer should comment on: "there's no test for the updating state".
  5. Tools like Codecov or SonarQube show exactly this as "patch coverage" on the PR. The number to gate on, if any, is this one, not the project's.
AdvancedPitfall: coverage as a target
100% and still broken

A PR adds isMaintenanceWindow(hour), which returns true from 2 a.m. up to but not including 5 a.m., with one test: assert.equal(isMaintenanceWindow(3), true). The implementation is return hour >= 2 && hour <= 5;. Line coverage for the file is 100%. What's wrong, and what tests would catch it?

Show answer

The implementation includes 5 a.m. (<= 5 should be < 5), and a single mid-window test can't see it. Boundary tests at 1, 2, 4 and 5 would.

What’s happening
  1. isMaintenanceWindow(3) executes the only line, so line coverage is 100%. Branch coverage doesn't help much either: there's no if, and both sides of the && ran.
  2. The bug is at the boundary: at hour = 5, 5 <= 5 is true, so devices could reboot at 5 a.m., an hour into the window nobody asked for.
  3. Tests at the edges: 1 → false, 2 → true, 4 → true, 5 → false. The last one fails against this implementation and passes once it's < 5.
  4. This is the mutation-testing lesson in one function: a mutant that turns < into <= survives a mid-range test, and dies to a boundary test.

#Flaky tests

Added

A flaky test passes and fails on the same code. Google's testing blog uses exactly that definition ("Flaky Tests at Google and How We Mitigate Them", John Micco, 27 May 2016) and gives the numbers that make it a leadership problem, not a nuisance: about 1.5% of all test runs reported a flaky result, almost 16% of tests had some level of flakiness, and about 84% of the pass-to-fail transitions their CI observed involved a flaky test. Most red builds weren't real breakages, and someone had to investigate each one.

The causes are well known. Fowler's "Eradicating Non-Determinism in Tests" (14 April 2011) lists them: lack of isolation (tests sharing state, so order matters), asynchronous behaviour (waiting with sleeps instead of for the actual event), remote services, time (tests that depend on the clock), and resource leaks. Web UIs add animations and timing in the browser, and APIs add shared test databases and ports.

The mental model: a smoke alarm that goes off when you make toast. After a week, someone takes the battery out, and then the alarm doesn't sound when there's a fire either. A flaky test does the same to a pipeline: people learn that red means "re-run", and a real failure gets re-run until it goes green too. The fix is a process, not heroics: detect flakes from data, quarantine them so they stop blocking everyone, fix them quickly, and bring them back.

flaky-detector.mjs
// Finds flaky tests in recorded CI results. A test is flaky when it both passed and failed on the SAME commit
// (Google's definition, 2016). A test that fails on every run from some commit onwards is a real break, not a flake.
// Policy here: flaky on 2+ commits → quarantine; once → watch.
const runs = [ // [commit, attempt, failed tests]; every other test in `tests` passed in that run
  ["a1", 1, []], ["a2", 1, ["e2e: join meeting"]], ["a2", 2, []],
  ["a3", 1, []], ["a4", 1, ["api: list devices paginates"]], ["a4", 2, []],
  ["a5", 1, ["e2e: join meeting"]], ["a5", 2, []], ["a6", 1, ["web: firmware badge"]],
  ["a6", 2, ["web: firmware badge"]], ["a7", 1, ["web: firmware badge", "e2e: join meeting"]],
  ["a7", 2, ["web: firmware badge"]], ["a8", 1, ["web: firmware badge"]],
];
const tests = ["e2e: join meeting", "api: list devices paginates", "web: firmware badge", "api: rename device"];

const byTest = new Map(tests.map((t) => [t, { runs: 0, fails: 0, failingCommits: new Set(), passingCommits: new Set() }]));
for (const [commit, , failed] of runs)
  for (const t of tests) {
    const s = byTest.get(t);
    s.runs++;
    if (failed.includes(t)) s.fails++, s.failingCommits.add(commit);
    else s.passingCommits.add(commit);
  }
const commits = [...new Set(runs.map(([c]) => c))];
console.log("test                          runs  fails  verdict");
for (const [t, s] of byTest) {
  const flaky = [...s.failingCommits].filter((c) => s.passingCommits.has(c));
  const firstBad = commits.find((c, i) => commits.slice(i).every((later) => s.failingCommits.has(later) && !s.passingCommits.has(later)));
  let verdict = "healthy";
  if (firstBad) verdict = `BROKEN since ${firstBad}: fix or revert, don't retry`;
  else if (flaky.length >= 2) verdict = `flaky on ${flaky.length}/${commits.length} commits (${flaky.join(", ")}): quarantine + ticket`;
  else if (flaky.length === 1) verdict = `flaked once (${flaky[0]}): watch`;
  console.log(`${t.padEnd(29)} ${String(s.runs).padStart(4)}  ${String(s.fails).padStart(5)}  ${verdict}`);
}
Result
test                          runs  fails  verdict
e2e: join meeting               13      3  flaky on 3/8 commits (a2, a5, a7): quarantine + ticket
api: list devices paginates     13      1  flaked once (a4): watch
web: firmware badge             13      5  BROKEN since a6: fix or revert, don't retry
api: rename device              13      0  healthy
What’s happening
  1. The input is what CI already records: for each run, the commit, the attempt number (re-runs of the same commit) and which tests failed. Eight commits, thirteen runs. This is the data GitHub's checks, a JUnit report or Playwright's JSON reporter give you.
  2. For each test the detector collects the commits where it failed and the commits where it passed. A commit in both sets is proof of flakiness by Google's definition: same code, different result.
  3. "e2e: join meeting" failed and passed on three of the eight commits (a2, a5, a7), so it's quarantined and ticketed. "api: list devices paginates" flaked once; one occurrence is worth watching, not yet worth pulling a test, which is a policy choice the team should write down.
  4. "web: firmware badge" fails on every run from a6 onwards, re-runs included. That's not flaky; that's broken since a6. The firstBad check looks for a commit after which the test never passes again, and the advice is the opposite of a flake's: don't retry, find the change (git bisect is made for this; see Finding the commit that broke it with git bisect).
  5. Separating these two cases is the whole value of the tool. Without it, a real breakage gets the flaky treatment ("just re-run") and ships, or a flake gets the breakage treatment and someone loses a day bisecting noise.
AdvancedOrder-dependent tests, caught with a seed

The most common flake in a large suite is a test that only passes because another test ran first and left state behind. Node 26.1 added randomised test order to the built-in runner (--test-randomize, and --test-random-seed=<n> to replay one order; marked "Stability: 1.0 - Early development" in the Node 26 docs). The same option exists in run() as randomSeed, which makes the flake reproducible. Because the feature is early development, a later Node release could map the same seed to a different order: the orders below are what Node 26.7.0 produced, and a seed is only a reliable replay on the same Node version.

order-flake.mjs
// An order-dependent test suite: three tests share one module-level Map. Node 26.1+ can shuffle test order with a
// seed (--test-random-seed, or randomSeed in run()), so the same seed always gives the same order and result.
import { test, beforeEach, run } from "node:test";
import assert from "node:assert/strict";

let devices = new Map(); // shared by every test in this file
if (process.env.NODE_TEST_CONTEXT) {
  if (process.env.FIXED) beforeEach(() => (devices = new Map([["dev-1", { name: "Room 1" }]]))); // each test gets its own state
  test("registers a device", () => {
    devices.set("dev-2", { name: "Room 2" });
    assert.ok(devices.has("dev-2"));
  });
  test("renames a device", () => {
    if (!process.env.FIXED) devices.set("dev-1", { name: "Room 1" }); // setup that only this test does
    devices.get("dev-1").name = "Boardroom";
    assert.equal(devices.get("dev-1").name, "Boardroom");
  });
  test("lists devices", () => assert.equal(devices.size, process.env.FIXED ? 1 : 2));
} else {
  for (const fixed of [false, true]) {
    console.log(fixed ? "With a fresh Map before each test:" : "Shared state:");
    for (const seed of [undefined, 1, 2, 5]) {
      const results = [];
      const env = { ...process.env, ...(fixed ? { FIXED: "1" } : {}) };
      for await (const { type, data } of run({ files: [import.meta.filename], randomSeed: seed, env })) {
        if (data?.nesting === 0 && type === "test:pass") results.push(`✔ ${data.name}`);
        if (data?.nesting === 0 && type === "test:fail") results.push(`✖ ${data.name}`);
      }
      console.log(`  ${seed === undefined ? "declared order" : `seed ${seed}`.padEnd(14)}  ${results.join(" → ")}`);
    }
  }
}
Result
Shared state:
  declared order  ✔ registers a device → ✔ renames a device → ✔ lists devices
  seed 1          ✔ renames a device → ✔ registers a device → ✔ lists devices
  seed 2          ✖ lists devices → ✔ registers a device → ✔ renames a device
  seed 5          ✖ lists devices → ✔ renames a device → ✔ registers a device
With a fresh Map before each test:
  declared order  ✔ registers a device → ✔ renames a device → ✔ lists devices
  seed 1          ✔ renames a device → ✔ registers a device → ✔ lists devices
  seed 2          ✔ lists devices → ✔ registers a device → ✔ renames a device
  seed 5          ✔ lists devices → ✔ renames a device → ✔ registers a device
What’s happening
  1. All three tests share one module-level Map. In declared order the suite passes: "registers" adds dev-2, "renames" adds dev-1 and renames it, and "lists" finds two devices. Nobody notices the hidden dependency, because nobody ever runs them in another order.
  2. With seed 2 and seed 5, "lists devices" runs first and finds an empty Map: 0 !== 2, it fails. The same seed always gives the same order, so the failure is reproducible on any machine running the same Node version with --test-random-seed=2, instead of "fails sometimes in CI".
  3. Seed 1 happens to keep "lists" last and passes. That's why a single shuffled run proves little: run with randomisation in CI and log the seed (Node prints it as "Randomized test order seed: …").
  4. The fix (FIXED=1, passed to the child process through run()'s env option) gives each test its own state in beforeEach. Now "lists" expects exactly the one device it was given, and every order passes. Each test arranges what it needs instead of relying on what ran before.
AdvancedTime as an input: fake clocks

Tests that wait for real time are slow if they wait long enough and flaky if they don't. A presence check that marks a device offline after 30 seconds of silence is the classic case: either the test sleeps 30 seconds, or someone shrinks the timeout to 30 ms and sleeps 35 ms, which fails whenever the CI machine is busy. node:test's mock timers (stable since Node 23.1) make time something the test controls:

presence-fake-clock.mjs
// A device is "offline" if no heartbeat arrived for 30 s. Testing that with real timers means sleeping 30 s, or
// shrinking the timeout and racing the scheduler (flaky). node:test's mock timers make time an input instead.
import { test, run } from "node:test";
import assert from "node:assert/strict";

export function createPresence({ timeoutMs, onOffline }) {
  const timers = new Map();
  const lastSeen = new Map();
  return {
    heartbeat(id) {
      clearTimeout(timers.get(id));
      lastSeen.set(id, Date.now());
      timers.set(id, setTimeout(() => onOffline(id, Date.now() - lastSeen.get(id)), timeoutMs));
    },
  };
}

if (process.env.NODE_TEST_CONTEXT) {
  test("goes offline exactly 30 s after the last heartbeat", (t) => {
    t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 0 });
    const offline = [];
    const presence = createPresence({ timeoutMs: 30_000, onOffline: (id, silentMs) => offline.push(`${id} after ${silentMs} ms`) });
    presence.heartbeat("dev-1");
    t.mock.timers.tick(29_999);
    assert.deepEqual(offline, []);
    t.mock.timers.tick(1);
    assert.deepEqual(offline, ["dev-1 after 30000 ms"]);
  });
  test("a heartbeat in time resets the countdown", (t) => {
    t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 0 });
    const offline = [];
    const presence = createPresence({ timeoutMs: 30_000, onOffline: (id) => offline.push(id) });
    presence.heartbeat("dev-1");
    t.mock.timers.tick(20_000);
    presence.heartbeat("dev-1"); // countdown restarts at t = 20 s
    t.mock.timers.tick(29_000);
    assert.deepEqual(offline, []);
    t.mock.timers.tick(1_000);
    assert.deepEqual(offline, ["dev-1"]);
  });
} else {
  for await (const { type, data } of run({ files: [import.meta.filename] })) {
    if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
    if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n  ${data.details.error.cause?.message}`);
  }
}
Result
✔ goes offline exactly 30 s after the last heartbeat
✔ a heartbeat in time resets the countdown
What’s happening
  1. t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 0 }) replaces setTimeout and Date for this test only, starting the clock at 0. Nothing waits for real time; the whole file runs in well under a second.
  2. After a heartbeat, tick(29_999) moves the fake clock forward 29.999 s: no timer is due, so offline is still empty. tick(1) reaches exactly 30 s, the timer fires, and Date.now() - lastSeen is exactly 30000. That precision is impossible with real timers.
  3. The second test sends a heartbeat at 20 s, which must restart the countdown: at 49 s (20 + 29) the device is still online, at 50 s it's offline. That's the bug most presence code has (forgetting clearTimeout), and this test would catch it every time.
  4. The mocks are scoped to the test context t, so they're restored when each test ends and can't leak into the next one, which would be an isolation flake of its own.
AdvancedQuarantine without forgetting

Fowler's advice is to quarantine non-deterministic tests, moving them out of the main suite so they stop eroding trust in the healthy ones, and to fix quarantined tests quickly. Google describes a tool that quarantines a test automatically when its flakiness gets too high. In Playwright, tag the test (test("joins a meeting", { tag: "@quarantine" }, …)), exclude the tag in CI with --grep-invert @quarantine (the ci.yml in the CI/CD chapter does), and run the quarantined tests on their own every night to see whether the fix worked:

.github/workflows/quarantine.yml
name: Quarantined tests

on:
  schedule:
    - cron: "40 2 * * *"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  quarantined:
    name: Quarantined E2E tests, 20 runs each
    runs-on: ubuntu-24.04
    timeout-minutes: 45
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: devices_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/devices_test
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci
      - run: npm run build --workspace web
      - run: npm run migrate --workspace api
      - run: npx playwright install --with-deps chromium
      - name: Run every quarantined test 20 times
        id: repeat
        continue-on-error: true
        env:
          PLAYWRIGHT_JSON_OUTPUT_NAME: quarantine.json
        run: npx playwright test --grep @quarantine --repeat-each=20 --reporter=json
      - name: Summarise pass rates
        if: always()
        run: node scripts/flake-summary.mjs quarantine.json >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: quarantine-${{ github.run_id }}
          path: quarantine.json
          retention-days: 30
What’s happening
  1. It runs at 02:40 UTC every night and on demand. It never runs on pull requests, so a quarantined test can't block anyone, but it still runs every day, so it can't be forgotten either.
  2. The job builds the same environment as the main E2E jobs (PostgreSQL service, migrations, Chromium), then runs only tests tagged @quarantine, each 20 times with --repeat-each=20. One run of a flaky test proves nothing; twenty give a pass rate.
  3. continue-on-error: true on that step keeps the job going when some repeats fail, which is expected here. The JSON reporter writes quarantine.json (PLAYWRIGHT_JSON_OUTPUT_NAME), and a small script in the repository (not shown; it applies the same pass/fail-per-commit logic as the detector above) appends a pass-rate table to the run's summary page through $GITHUB_STEP_SUMMARY.
  4. The report is kept 30 days as an artefact, so the trend is visible. The team rule that makes it work: every quarantined test has an owner and a ticket; a test that passes 20 of 20 for a week comes back into the main suite; one still quarantined after the time limit (say two weeks) is either fixed or deleted.
AdvancedCauses and fixes at a glance
CauseHow it shows upFix
Shared state between testspasses alone, fails in the suite; depends on orderfresh state per test (beforeEach, a transaction rolled back, a database per worker); run with randomised order
Sleeping instead of waitingfails on a busy CI machinewait for the event: Playwright's auto-waiting locators and expect(...).toBeVisible(), a promise that resolves on the event
Real timefails at midnight, month end, in another time zone, or on DST daysfake clocks (mock.timers), pass now in, set TZ in CI deliberately
Real network and other servicesfails when a sandbox API is slow or rate-limitsstub at the edge, contract-test the real one separately
Ports, files, leaked resourcesfails when tests run in parallelport 0, temporary directories, close servers in after hooks
Randomnessfails one run in a few hundredseed the generator and print the seed
Test data collisionsfails when two shards create the same userunique ids per test or per worker
A real race in the productfails rarely, in a patternit's a bug: fix the code, not the test
AdvancedThe lead's part: process, not heroics

Flakiness is a shared-resource problem: everyone pays for it, nobody owns it, so it grows unless someone makes it visible. What tends to work on a team spread across time zones:

  • A weekly flake report from CI data (the detector above, or the test analytics in your CI tool): top flaky tests, how many runs they turned red, who owns each. Five minutes in a team meeting.
  • An owner for every quarantined test and a time limit (say two weeks): fix it, rewrite it at a cheaper level, or delete it with a note on what it was protecting.
  • A rotating "build cop" whose job that week is to keep main green: triage every red run within the hour, revert or quarantine, and hand over at the end of the day. With engineers in India and the US, the handover note is the difference between a green morning and a lost day.
  • No silent retries. If the suite retries, retried passes are reported as flaky (Playwright does this; --fail-on-flaky-tests can make them fail the run once the backlog is cleared).
  • Count the cost in hours. Google's figure that 84% of pass-to-fail transitions involved a flaky test is a useful opener when asking a client for time to fix the suite.
AdvancedPitfall: retries that hide a real race
Flaky or broken?

CI history for "api: firmware rollout status": commit c1 passed; c2 failed, then passed on re-run; c3 failed twice; c4 failed; c5 failed, then failed on re-run. What do you do?

Show answer

Treat it as broken since c3, not flaky: bisect between c2 and c3. The c2 flake is a separate, smaller problem.

What’s happening
  1. On c2 the test both failed and passed with the same code, so it's flaky by definition on that commit.
  2. From c3 onwards it never passes again: c3 twice, c4, c5 twice. That pattern means a change in c3 (or one merged with it) broke it, the "BROKEN since" rule in the detector above.
  3. The right action is the one for a breakage: find the change with git bisect between c2 (last pass) and c3, then fix or revert. Re-running would just burn CI minutes.
  4. Afterwards, look at the c2 flake on its own, ideally with randomised order or repeats, because a test that's both flaky and now broken has two problems.

#Adding tests to a legacy codebase

Added

Michael Feathers's Working Effectively with Legacy Code (Prentice Hall, 2004) defines legacy code simply as code without tests, and gives the two ideas that make it workable. A characterisation test records what the code actually does today, not what it should do: you call it with interesting inputs, write down what came back, and assert exactly that, quirks included. A seam is, in Feathers's words, "a place where you can alter behavior in your program without editing in that place": the point where a test can substitute the clock, the database or the network.

You don't start by writing tests for the whole codebase. You start with the code you're about to change: pin its current behaviour, make the change, and leave it better tested than you found it. Feathers's legacy code change algorithm is: identify the change points, find where to test, break the dependencies that stop you testing, write the tests, then make the change and refactor.

The mental model: photograph every room before renovating an old house. You don't know why the light switch in the hall also turns on the porch light, and it might matter to someone. The photos aren't a judgement that the house is right; they're how you'll know afterwards what you changed. Characterisation tests are those photos.

characterise.mjs
// Characterisation tests (Feathers, 2004): before changing legacy code, pin down what it DOES today, quirks included.
// Then refactor, and let the pinned behaviour tell you what you changed by accident.
import { test, run } from "node:test";
import assert from "node:assert/strict";

// Legacy: shows a call's length on the device panel. No tests, no spec, the author left in 2021.
function formatDurationLegacy(s) {
  if (!s) return "0:00";
  var m = Math.floor(s / 60), sec = s % 60;
  if (m >= 60) return Math.floor(m / 60) + "h " + (m % 60) + "m";
  return m + ":" + (sec < 10 ? "0" + sec : sec);
}
// The tidy rewrite someone wants to merge.
function formatDurationRefactored(seconds) {
  if (!seconds) return "0:00";
  const minutes = Math.floor(seconds / 60);
  if (minutes >= 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
  return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
}

// Step 1 was to run the legacy function over interesting inputs and paste what came back. These are its answers:
const pinned = [
  [0, "0:00"], [5, "0:05"], [65, "1:05"], [3599, "59:59"], [3600, "1h 0m"], [7322, "2h 2m"],
  [null, "0:00"], ["90", "1:30"], [59.6, "0:59.6"], [-30, "-1:0-30"],
];
const check = (fn) => {
  const diffs = pinned.filter(([input, want]) => fn(input) !== want)
    .map(([input, want]) => `${JSON.stringify(input)}: was ${JSON.stringify(want)}, now ${JSON.stringify(fn(input))}`);
  assert.equal(diffs.length, 0, diffs.join("; "));
};

if (process.env.NODE_TEST_CONTEXT) {
  test("legacy behaviour is pinned", () => check(formatDurationLegacy));
  test("refactor keeps the pinned behaviour", () => check(formatDurationRefactored));
} else {
  for await (const { type, data } of run({ files: [import.meta.filename] })) {
    if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
    if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n  ${data.details.error.cause.message.split("\n\n")[0].replaceAll("; ", "\n  ")}`);
  }
}
Result
✔ legacy behaviour is pinned
✖ refactor keeps the pinned behaviour
  59.6: was "0:59.6", now "0:59"
  -30: was "-1:0-30", now "-1:-30"
What’s happening
  1. formatDurationLegacy formats a call's length for the device's panel. There's no spec and no test, so the first step (done before writing this file) was to call it with a spread of inputs, ordinary, boundary and odd, and paste what it returned into pinned. Nobody decided those values are right; they're what production does today.
  2. Some of them are clearly quirks: "90" (a string) gives "1:30" thanks to coercion, 59.6 gives "0:59.6", and -30 gives "-1:0-30". A characterisation test pins those too, because some caller might depend on them, and you want to change them deliberately, not by accident.
  3. The first test passes: the legacy function matches its own photographs, as it must. If it didn't, the inputs weren't recorded correctly.
  4. The tidy refactor fails on exactly two inputs and passes the other eight. It renders 59.6 as "0:59" and -30 as "-1:-30". Both are arguably better, and both are behaviour changes. The test turned an invisible change into a visible decision.
  5. Now the conversation is concrete: can the device ever send fractional or negative durations? If not, update those two pinned values in the same PR, with a note saying why. If nobody knows, keep the old behaviour for now. Either way, the decision is recorded in a test instead of being discovered by a customer.
AdvancedMaking a seam

The usual reason legacy code has no tests is that it reaches out for its dependencies itself: new Date(), a global HTTP client, a database singleton. The smallest safe edit is to make each dependency a parameter with the old default, so every existing caller behaves exactly as before, and tests can pass their own:

seam.mjs
// A seam (Feathers): a place where you can change what code does without editing it there. The legacy function
// reached for the clock and the network itself; one small edit turns both into parameters with the old defaults.
import { test, run } from "node:test";
import assert from "node:assert/strict";

// Before: untestable without a real device API and waiting for 2 a.m.
//   export async function rebootIfIdle(device) {
//     const hour = new Date().getHours();
//     if (hour < 2 || hour > 4 || device.inCall) return "skipped";
//     await fetch(`https://devices.internal/api/v1/devices/${device.id}/reboot`, { method: "POST" });
//     return "rebooted";
//   }
// After: same behaviour for every existing caller, but tests can pass their own clock and HTTP client.
export async function rebootIfIdle(device, { now = () => new Date(), post = (url) => fetch(url, { method: "POST" }) } = {}) {
  const hour = now().getHours();
  if (hour < 2 || hour > 4 || device.inCall) return "skipped";
  await post(`https://devices.internal/api/v1/devices/${device.id}/reboot`);
  return "rebooted";
}

if (process.env.NODE_TEST_CONTEXT) {
  const at = (h) => () => new Date(2026, 9, 27, h, 30); // local time, so getHours() is h in any time zone
  test("reboots an idle device at 03:30", async () => {
    const calls = [];
    assert.equal(await rebootIfIdle({ id: "dev-1", inCall: false }, { now: at(3), post: async (url) => calls.push(url) }), "rebooted");
    assert.deepEqual(calls, ["https://devices.internal/api/v1/devices/dev-1/reboot"]);
  });
  test("never reboots a device that is in a call", async () => {
    const calls = [];
    assert.equal(await rebootIfIdle({ id: "dev-1", inCall: true }, { now: at(3), post: async (url) => calls.push(url) }), "skipped");
    assert.deepEqual(calls, []);
  });
  test("leaves devices alone during the working day", async () => {
    assert.equal(await rebootIfIdle({ id: "dev-1", inCall: false }, { now: at(10), post: async () => assert.fail("no POST") }), "skipped");
  });
} else {
  for await (const { type, data } of run({ files: [import.meta.filename] })) {
    if (type === "test:pass" && data.nesting === 0) console.log(`✔ ${data.name}`);
    if (type === "test:fail" && data.nesting === 0) console.log(`✖ ${data.name}\n  ${data.details.error.cause?.message}`);
  }
}
Result
✔ reboots an idle device at 03:30
✔ never reboots a device that is in a call
✔ leaves devices alone during the working day
What’s happening
  1. The commented "before" version reads the real clock and calls the real devices API. Testing it means running at 2 a.m. against a real device, so nobody did.
  2. The "after" version takes an options object with two defaults: now returns new Date() and post calls fetch. Callers that pass nothing get the old behaviour; the signature change is backwards compatible. This is Feathers's "parameterise method", one of the dependency-breaking techniques in the book.
  3. The tests pass a fixed clock (at(3) is 03:30 local time on 27 October 2026, built with the local-time Date constructor so getHours() is 3 in any time zone; the file was also run with TZ=Asia/Kolkata and passed) and a fake post that records URLs. They check the outcome and the exact call made.
  4. The second and third tests cover the reasons not to reboot: a device in a call, and a time outside the window. Those are the cases that matter most to a customer, and the hardest to test against the real thing. assert.fail("no POST") makes the fake throw if anything tries to reboot.
  5. Only now is it safe to change the logic (say, to use the room's time zone instead of the server's), because the current behaviour is pinned and the dependencies can be controlled.
AdvancedWhere to start: hotspots

A codebase with no tests has more untested code than any team can cover, so pick the places where tests pay back fastest: files that are complicated and change often. Adam Tornhill's Your Code as a Crime Scene (second edition, Pragmatic Bookshelf, February 2024) calls these hotspots and finds them from version-control history. This model does the same with real git on a throwaway repository:

hotspots.mjs
// Where to start testing a legacy codebase: files that change often AND are complicated (a "hotspot").
// Churn comes from git history (commits touching the file since a fixed date); complexity is a crude count of
// branches in the current file. A throwaway repo with fixed names and dates keeps the output deterministic.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "hot-"));
const git = (args, date) => execFileSync("git", args, { cwd: dir, encoding: "utf8", env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
  GIT_AUTHOR_NAME: "Dev", GIT_AUTHOR_EMAIL: "dev@example.com", GIT_COMMITTER_NAME: "Dev", GIT_COMMITTER_EMAIL: "dev@example.com",
  ...(date && { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }) } }).trim();
const branchy = (n, tag) => Array.from({ length: n }, (_, i) => `if (x === ${i}) return "${tag}${i}";`).join("\n") + "\n";
const write = (file, text) => (mkdirSync(join(dir, dirname(file)), { recursive: true }), writeFileSync(join(dir, file), text));
git(["init", "-q", "-b", "main"]);

const sizes = { "api/devices.js": 25, "api/provisioning.js": 60, "web/Settings.tsx": 12, "lib/strings.js": 40, "api/health.js": 2 };
const touches = { // how many commits touched each file in the last quarter
  "api/devices.js": 14, "api/provisioning.js": 9, "web/Settings.tsx": 16, "lib/strings.js": 1, "api/health.js": 1,
};
for (const [f, n] of Object.entries(sizes)) write(f, branchy(n, "v0-"));
git(["add", "."]);
git(["commit", "-qm", "Import from the old repo"], "2025-11-03T09:00:00Z");
for (let day = 1; day <= 16; day++) {
  const date = `2026-08-${String(day).padStart(2, "0")}T10:00:00Z`;
  for (const [f, n] of Object.entries(touches)) if (day <= n) write(f, branchy(sizes[f], `v${day}-`));
  git(["add", "."]);
  git(["commit", "-qm", `Change ${day}`], date);
}

const log = git(["log", "--since=2026-07-01T00:00:00Z", "--name-only", "--format="]).split("\n").filter(Boolean);
const churn = Object.fromEntries(Object.keys(sizes).map((f) => [f, log.filter((x) => x === f).length]));
const complexity = (f) => (readFileSync(join(dir, f), "utf8").match(/\b(if|for|while|case)\b|&&|\|\||\?/g) || []).length;
const rows = Object.keys(sizes).map((f) => ({ f, churn: churn[f], cx: complexity(f), score: churn[f] * complexity(f) }))
  .sort((a, b) => b.score - a.score);
console.log("file                  commits  branches  hotspot score");
for (const r of rows) console.log(`${r.f.padEnd(21)} ${String(r.churn).padStart(7)}  ${String(r.cx).padStart(8)}  ${String(r.score).padStart(13)}`);
rmSync(dir, { recursive: true, force: true });
Result
file                  commits  branches  hotspot score
api/provisioning.js         9        60            540
api/devices.js             14        25            350
web/Settings.tsx           16        12            192
lib/strings.js              1        40             40
api/health.js               1         2              2
What’s happening
  1. The script builds a repository with five files of different sizes, imports them in November 2025, then makes sixteen daily commits in August 2026 that touch each file a set number of times. Fixed names and dates, and GIT_CONFIG_GLOBAL=/dev/null so nobody's own Git settings change the result.
  2. Churn is git log --since=2026-07-01T00:00:00Z --name-only counted per file: how often each file changed in the last quarter. The date is absolute, not "90 days ago", so the output doesn't change as time passes. The import commit is older and isn't counted.
  3. Complexity here is a crude count of branch keywords and operators in the current file. Real tools use cyclomatic complexity or indentation depth; for ranking, crude is enough.
  4. api/provisioning.js tops the list (9 changes × 60 branches) even though web/Settings.tsx changed more often: it's both complicated and active, which is where bugs and slow reviews come from. lib/strings.js is complicated but stable; it can wait. Characterisation tests go to the top two first.
  5. On a real repository, run the same git log over the last three to six months; the ranking is a good input for a conversation with the team, which knows things the history doesn't (that provisioning.js is being replaced next quarter, say).
AdvancedA golden master for a line-based API

Device platforms often have a text control protocol (Telnet or SSH sessions that room-control systems and scripts drive) whose real specification is "whatever the current version does". A golden master test records real sessions against the current implementation and replays them against the new one. The command set below is invented for the example; it isn't any vendor's:

golden-transcript.mjs
// A golden-master test for a line-based control API (Telnet-style). The command set is invented for this example.
// A session recorded against the legacy implementation is replayed against the rewrite; any different reply fails.
const transcript = `
> get volume
< volume 50
> set volume 40
< ok
> get volume
< volume 40
> SET VOLUME 70
< ok
> set volume 140
< error 3: value out of range
> set volume loud
< error 2: bad argument
> reboot now
< error 1: unknown command
`;
function createRewrite() { // the new implementation someone wants to ship
  let volume = 50;
  const commands = {
    "get volume": () => `volume ${volume}`,
    "set volume": (arg) => {
      if (!/^\d+$/.test(arg ?? "")) return "error 2: bad argument";
      if (Number(arg) > 100) return "error 3: value out of range";
      volume = Number(arg);
      return "ok";
    },
  };
  return (line) => {
    const [verb, noun, arg] = line.trim().split(/\s+/);
    const handler = commands[`${verb} ${noun}`];
    return handler ? handler(arg) : "error 1: unknown command";
  };
}
const steps = [];
for (const [, cmd, reply] of transcript.matchAll(/^> (.*)\n< (.*)$/gm)) steps.push({ cmd, reply });
const send = createRewrite();
let failures = 0;
for (const { cmd, reply } of steps) {
  const got = send(cmd);
  if (got !== reply) failures++;
  console.log(`${got === reply ? "same" : "DIFF"}  > ${cmd.padEnd(16)} recorded "${reply}"${got === reply ? "" : `, rewrite "${got}"`}`);
}
console.log(`${steps.length} exchanges replayed, ${failures} different`);
Result
same  > get volume       recorded "volume 50"
same  > set volume 40    recorded "ok"
same  > get volume       recorded "volume 40"
DIFF  > SET VOLUME 70    recorded "ok", rewrite "error 1: unknown command"
same  > set volume 140   recorded "error 3: value out of range"
same  > set volume loud  recorded "error 2: bad argument"
same  > reboot now       recorded "error 1: unknown command"
7 exchanges replayed, 1 different
What’s happening
  1. The transcript is a recorded session: > lines were sent, < lines came back from the legacy implementation. Recording from a staging device or from logs (with anything personal removed) captures behaviour nobody wrote down, including the error codes and their exact wording, which integrators' scripts often match on.
  2. The replay parses each exchange with one regular expression, sends the command to the rewrite and compares the reply character for character. Six of seven match: values, range errors, bad arguments and unknown commands all behave the same.
  3. SET VOLUME 70 differs: the legacy implementation accepted commands in any case and the rewrite only knows lower case. Any room-control script that sends upper case would break on upgrade, and no unit test of the new parser would have thought to check.
  4. The decision is now explicit: lower-case the input in the rewrite (keep compatibility), or document a breaking change for the next major version. Either way, add that transcript line to the permanent test suite so the choice stays enforced.
AdvancedA practical plan for the first months
  • Rule one: every change comes with tests for what it touches. The boy-scout rule (Google's coverage post uses the phrase): leave the code a little better tested than you found it. Coverage then grows where the work is.
  • Every bug fix starts with a failing test that reproduces it. It proves the fix, and it's the cheapest characterisation test you'll ever write, aimed at a place that's already proven fragile.
  • Pin the API from the outside. For a legacy REST or Telnet API with incomplete documentation, record real requests and responses (from a staging environment or logs, with personal data removed) and replay them as golden-master tests. They become the documentation nobody wrote. Telnet and other line-based device protocols record well as plain-text transcripts.
  • Test new code properly, from day one. "Sprout" new behaviour as a new, tested function and call it from the legacy code, rather than growing the untested function further.
  • Don't refactor without the photos. No large refactor until the behaviour it touches is characterised. Agree that rule with the client's engineers, who often know the quirks the tests are pinning.
  • Make the suite run in CI immediately, even if it's tiny, and make it required. A test suite that only runs on one laptop isn't protecting anyone.
AdvancedPitfall: fixing quirks while pinning them
First test for a scary function

You need to change how calculateLicenceRenewal(device) rounds prices. It's 400 lines, reads the current date, calls the pricing service over HTTP and has no tests. What do you do before changing the rounding?

Show answer

Make seams for the clock and the pricing call, then write characterisation tests over a spread of real inputs and pin the current outputs. Only then change the rounding, and update the pinned values that change, deliberately.

What’s happening
  1. Break the two dependencies with the smallest safe edits: add { now = () => new Date(), getPrice = realPricingCall } = {} as a parameter with defaults, so production callers are unaffected.
  2. Collect inputs that matter: a few real devices (anonymised), licence dates either side of month ends and leap days, every price tier, and the odd ones from production logs.
  3. Run the function with a fixed clock and a fake pricing response, record the outputs and pin them. The suite passes against the legacy code by construction.
  4. Now change the rounding. The tests that fail are exactly the cases whose price changes; check each one with whoever owns pricing, then update those pinned values in the same PR. Everything else is proven unchanged.

Written fresh for a lead full stack role. Git examples were run; GitHub Actions workflows were checked with actionlint. Behavioural stories are yours to write in My notes.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.