Testing & Quality
Testing & Quality

Designing code you can test

Full · ~40 min
After this unit you can
  • Extract decision logic into a pure function that takes everything it depends on — including the current time — as arguments, leaving a thin shell that supplies the real values
  • Add a dependency seam (pass the clock, fetch or a mailer in) so a test substitutes a double without mocking a module
  • Explain why module-level state and same-module calls resist tests, and restructure them with a factory or an injected dependency
  • Explain the real rule behind "you can't use enums in tests" — Node's type stripping and erasableSyntaxOnly reject enum, an ambient const enum has no runtime object, a factory mock drops it — and use a union or an as const object instead

Read

Simple
1 / 2

Pull the clock out, and the test writes itself

Flag
// ❌ The decision and the clock are tangled together
export function dueLabel(dueAt: number): string {
  const hoursLeft = (dueAt - Date.now()) / 3_600_000;
  if (hoursLeft < 0) return "Overdue";
  if (hoursLeft < 24) return `Due in ${Math.ceil(hoursLeft)}h`;
  return "Due later";
}

To test "Overdue" you need a dueAt in the past relative to whenever the test runs. So the test computes it from Date.now() — re-implementing the logic it's meant to check — or installs fake timers, or is green today and red at midnight. The function is hard to test because it reads something that isn't one of its arguments.

Move that read out, and pass the value in:

// ✅ Pure: everything it depends on arrives as an argument
export function dueLabel(now: number, dueAt: number): string {
  const hoursLeft = (dueAt - now) / 3_600_000;
  if (hoursLeft < 0) return "Overdue";
  if (hoursLeft < 24) return `Due in ${Math.ceil(hoursLeft)}h`;
  return "Due later";
}
const NOON = Date.UTC(2026, 2, 10, 12);

it("labels a task by the time left", () => {
  expect(dueLabel(NOON, NOON - 1)).toBe("Overdue");
  expect(dueLabel(NOON, NOON + 90 * 60_000)).toBe("Due in 2h");
  expect(dueLabel(NOON, NOON + 30 * 3_600_000)).toBe("Due later");
});

No doubles, no fake timers, no setup. The caller — the thin shell — supplies the real value: dueLabel(Date.now(), task.dueAt).

A pure function returns the same output for the same inputs and changes nothing outside itself. react.dev's two-line version: it "minds its own business", and "same inputs, same output". The usual reasons a function isn't pure, each of which belongs in the shell:

  • the time — Date.now(), new Date()
  • randomness — Math.random(), crypto.randomUUID()
  • I/O — fetch, the database, localStorage, the file system
  • the environment — process.env, window.location
  • state that outlives the call — a module-level variable

Keep the decisions in a pure core and push those reads to the edge. The core gets tested with plain values; the shell is so thin there's little left in it to get wrong.

Medium
1 / 2

Extracting the logic out of a component

Flag

A task list that fetches, filters, sorts and labels — all inside the component:

export function TaskList({ filter }: { filter: "all" | "overdue" }) {
  const [tasks, setTasks] = useState<Task[]>([]);
  useEffect(() => {
    fetch("/api/tasks").then((r) => r.json()).then(setTasks);
  }, []);

  const visible = tasks
    .filter((t) => filter === "all" || t.dueAt < Date.now())
    .sort((a, b) => a.dueAt - b.dueAt);

  return (
    <ul>
      {visible.map((t) => (
        <li key={t.id}>{t.title} — {dueLabel(Date.now(), t.dueAt)}</li>
      ))}
    </ul>
  );
}

To test "overdue tasks come first, sorted by due date" you have to render the component, intercept fetch, wait for the effect, fake the clock, and read the order out of the DOM. The rule you care about is three lines; the test is thirty.

Split it by job:

// tasks.ts — pure. No React, no fetch, no clock.
export function visibleTasks(tasks: Task[], filter: "all" | "overdue", now: number): Task[] {
  return tasks
    .filter((t) => filter === "all" || t.dueAt < now)
    .toSorted((a, b) => a.dueAt - b.dueAt);
}
// TaskList.tsx — the shell: fetches, owns the clock, renders
export function TaskList({ filter, now }: { filter: "all" | "overdue"; now: number }) {
  const tasks = useTasks(); // the fetch lives in a hook
  return (
    <ul>
      {visibleTasks(tasks, filter, now).map((t) => (
        <li key={t.id}>{t.title} — {dueLabel(now, t.dueAt)}</li>
      ))}
    </ul>
  );
}

Now each rule is a one-line test in the Node environment — no DOM at all:

expect(visibleTasks([later, earlier], "all", NOON).map((t) => t.id)).toEqual([earlier.id, later.id]);
expect(visibleTasks([pastDue, future], "overdue", NOON)).toEqual([pastDue]);

and the component needs one rendering test to prove the wiring, not one per rule.

Three things moved, each for a reason:

  • The decision went to a .ts file with no imports from React. That file can be tested as fast as any function, and reused on the server.
  • The fetch went to a hook. One place handles loading and errors; the component stops caring.
  • The clock became a prop. A component that calls Date.now() while rendering returns different output for the same props and state. React assumes "every component you write is a pure function"; react.dev's own clock example keeps new Date() and setInterval in a hook and passes time to the component as a prop.

toSorted returns a new array (ES2023); sort would reorder the array the caller passed in — a side effect hiding inside a "pure" function.

Complex

The real rule behind "you can't use enums in tests"

Flag

The claim is half true, which is why it survives. Vitest has no problem with enums: under Vitest 4.1 (Vite 8), a test importing enum Status { Active = "active" } from another file passes, and so does one importing a const enum declared in a .ts file. The claim comes from four other places. Each was checked by running it.

1. Node's own test runner on .ts files. Node strips TypeScript types by default since 22.18 and 23.6 (stable since 24.12 and 25.2), and node --test picks up *.test.ts. Stripping only erases types. An enum isn't a type — it compiles to a JavaScript object — so there's nothing to erase, and Node 24 refuses the file:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

The old escape hatch, --experimental-transform-types (added in Node 22.7), was removed in Node 26.

2. erasableSyntaxOnly (TypeScript 5.8+). Projects that run through type stripping turn this on so the type checker reports what Node would reject. It errors on enum and const enum alike — everywhere, tests included:

status.ts(1,13): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

3. An ambient const enum under a per-file transpiler. A declare const enum — usually met in a library's .d.ts — has no JavaScript object at all; tsc replaces each use with the literal value. Vite, esbuild and Babel compile one file at a time and never read the .d.ts, so Color.Red stays a property read on an import that doesn't exist:

TypeError: Cannot read properties of undefined (reading 'Red')        (Vitest 4.1)
error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.   (tsc)

isolatedModules: true (Next.js projects have it on) is the setting that makes tsc warn you.

4. A module mock deletes it. An enum is a value export. A vi.mock factory that doesn't return it removes it like any other value:

Error: [vitest] No "Role" export is defined on the "./api" mock.

The same happens to an as const object. A union type can't be lost this way — it was erased before the test ran.

The rule

An enum is runtime code, not a type. Tools that only erase types (Node's type stripping, erasableSyntaxOnly) reject it; per-file transpilers can't inline an ambient const enum; and a module mock removes it like any other export.

In a codebase that wants to run under type stripping — or just never think about this again — use a union for the type, plus an as const object when you need the values at runtime:

export const Role = { Admin: "admin", Member: "member" } as const;
export type Role = (typeof Role)[keyof typeof Role]; // "admin" | "member"

That file runs under node --test with plain type stripping. The language side — what an enum compiles to, as const, satisfies, exhaustive checks on a union — is in Narrowing and unions.

Quiz

dueLabel(dueAt) computes "Overdue" / "Due in 3h" from dueAt - Date.now(). What makes it hard to test?

Flag

Which of these is a pure function?

Flag

remindOverdue(tasks, { now, sendEmail }) takes its clock and mailer as arguments. What does that buy you over vi.mock("./mailer") plus fake timers?

Flag

rate-limit.ts keeps const hits = new Map() at module level. Test A uses up the limit for 1.2.3.4; test B in the same file then fails. The best fix?

Flag

clock.ts exports now() and stamp(), and stamp calls now. A test mocks now with vi.mock + importOriginal; now() returns the fake, but stamp() still uses the real time. Why?

Flag

A teammate insists "enums don't work in tests". Their tests are *.test.ts files run with node --test on Node 24, and the code under test declares enum Status { … }. What actually happens?

Flag

Under Vitest 4.1 (no special config), which of these fails at runtime?

Flag

vi.mock("./api", () => ({ fetchUser: vi.fn() })) fails with No "Role" export is defined on the "./api" mock, because the code under test compares user.role === Role.Admin. Without touching the factory, which change to api.ts (and that comparison) stops the error?

Flag

Fill in the blank

Make dueLabel pure: the current time becomes its first argument, named now.

Flag
export function dueLabel(: number, dueAt: number): string {
  const hoursLeft = (dueAt - ) / 3_600_000;
  if (hoursLeft < 0) return "Overdue";
  return hoursLeft < 24 ? `Due in ${Math.ceil(hoursLeft)}h` : "Due later";
}

Give the seam defaults so production call sites don't change, while tests pass their own clock and mailer.

Flag
export async function remindOverdue(
  tasks: Task[],
  { now = , sendEmail = mailer.send }: Partial<ReminderDeps> = {},
) { /* … */ }

Replace enum Role { Admin = "admin", Member = "member" } with something Node's type stripping accepts.

Flag
export const Role = { Admin: "admin", Member: "member" } as ;
export type Role = (typeof Role)[ typeof Role]; // "admin" | "member"

Name the two tsconfig.json options that make tsc report these problems before a test runner does.

Flag
"": true, // TS 5.8+: error on enum, namespaces with code, parameter properties
"": true  // error on code a one-file-at-a-time transpiler can't compile, e.g. an ambient const enum

Predict the output

// clock.ts
export function now() { return "real now"; }
export function stamp() { return `at ${now()}`; }

// clock.test.ts
import { now, stamp } from "./clock";
vi.mock("./clock", async (importOriginal) => ({
  ...(await importOriginal<typeof import("./clock")>()),
  now: () => "fake now",
}));

console.log(now(), "|", stamp());
Flag
// status.ts
export enum Status { Active = "active", Closed = "closed" }

// status.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { Status } from "./status.ts";

test("active", () => assert.equal(Status.Active, "active"));

// $ node --test status.test.ts    (Node 24)
Flag

Challenge

Make the resend-code cooldown testable

Flag

ResendButton lets a user ask for another sign-in code, at most once a minute. QA reports that the label sometimes reads "Resend in 0s". You can't write a test that reproduces it:

  • the label is computed inside the component from Date.now(), so a test would have to wait a real minute (or fake the clock) and then read it out of the DOM;
  • the cooldown lives in a module-level lastSentAt, so one test's send blocks the next test;
  • sending calls the network module directly, so a test can't see what was sent without mocking a module.

Refactor so the rules can be tested with plain values, then let the tests find the bugs. The grader imports two new exports from ResendButton.tsx:

resendLabel(now: number, lastSentAt: number): string
// "Resend code" once the 60 s cooldown is over, otherwise "Resend in 42s"

createCodeSender({ now, send }): (email: string) => Promise<SendResult>
// SendResult = { ok: true; sentAt: number } | { ok: false; retryInSeconds: number }
// if `send` throws, the promise rejects with that error: no code went out

now is a function returning epoch milliseconds (production passes Date.now); send delivers the code (production passes postSendCode). Keep the component working — it becomes the thin shell that reads the real clock and passes it in.

Your task

Edit ResendButton.tsx so the tests pass:

  • resendLabel(now, lastSentAt) is exported and counts down from the now it is given, not the real clock
  • Exactly 60 s after a send, the label is "Resend code"
  • The seconds left round up, so the label never says "Resend in 0s"
  • createCodeSender({ now, send }) sends the first code at once, refuses inside the cooldown without sending (returning the seconds to wait, rounded up), and sends again once 60 s have passed on the injected clock
  • Two requests in quick succession — a double click — send only one code
  • If send fails, the request rejects with that error and doesn't start the cooldown, so the user can ask again at once
  • Two senders keep separate cooldowns

Loading the in-browser editor…

Further reading