42 β Testing with Vitest
Level: Advanced Β· Time: ~40 min Β· Prerequisites: Lesson 41 β The Full-Stack Project β Build, Configuration and Deployment
Why this matters
A test suite turns "I think this still works" into evidence. It buys regression safety, as a broken March total fails
in seconds rather than in a customer's browser, and documented intent, because a test named refuses a capacity of
zero states the rule where a comment would rot.
Setting up Vitest in a TypeScript Node project
Vitest reuses Vite's transform pipeline, so a TypeScript project needs a config file and almost nothing else.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node", // "jsdom" for DOM tests, with the jsdom package installed
include: ["src/**/*.test.ts"],
restoreMocks: true,
coverage: { provider: "v8", reporter: ["text", "html"], include: ["src/**/*.ts"] },
},
});
Test files sit beside the code they test, named *.test.ts, and the include pattern defines the suite β a
file outside it never runs. Assertions come from "vitest" unless you enable globals: true. The runner never
typechecks: Vitest strips types with esbuild as the dev server did in Lesson 37, so npx tsc -b is a second gate.
Unit tests of domain logic, table-driven cases and edge cases
Pure functions are where tests pay best: no clock, no network, no database.
// src/domain/yield.ts
export interface ProductionDay {
readonly date: string;
readonly region: string;
readonly kwh: number;
readonly capacityKwp: number;
}
export function specificYield(day: ProductionDay): number {
if (day.capacityKwp <= 0) throw new RangeError(`capacity must be positive, got ${day.capacityKwp}`);
return day.kwh / day.capacityKwp;
}
// src/domain/yield.test.ts
import { describe, expect, it } from "vitest";
import { specificYield, type ProductionDay } from "./yield.js";
const day = (kwh: number, capacityKwp = 3): ProductionDay =>
({ date: "2026-03-14", region: "Dakar", kwh, capacityKwp });
describe("specificYield", () => {
it.each([[18, 3, 6], [0, 3, 0], [7.5, 2.5, 3]])("%d kWh at %d kWp is %d kWh/kWp",
(kwh, capacityKwp, expected) => {
expect(specificYield(day(kwh, capacityKwp))).toBeCloseTo(expected, 10);
});
it("refuses a capacity of zero instead of returning Infinity", () => {
expect(() => specificYield(day(18, 0))).toThrow(RangeError);
});
});
A table-driven test states the rule for three inputs without repeating itself. Zero and negative inputs are where
the bugs live, so use toBeCloseTo for floating point and toThrow with the error class.
Async code, fake timers and controlled randomness
An async assertion that is not awaited finishes after the test does, so the test passes while the claim is in flight. The helper below starts a timer that aborts a request; the test proves a five-second timeout in milliseconds.
// src/http/timeout.ts
export async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try { return await fetch(url, { signal: controller.signal }); }
finally { clearTimeout(timer); }
}
// src/http/timeout.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWithTimeout } from "./timeout.js";
describe("fetchWithTimeout", () => {
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it("aborts a request that outlives its budget", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_ok, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
})));
const assertion = expect(fetchWithTimeout("https://power.larc.nasa.gov/api", 5_000)).rejects.toThrow(/abort/i);
await vi.advanceTimersByTimeAsync(5_000);
await assertion;
});
});
[!WARNING] Attach the
expectbefore advancing time, or the rejection is unhandled and Node reports a crash. UseadvanceTimersByTimeAsyncrather than its synchronous twin, so pending microtasks are flushed.
Randomness becomes a parameter the same way: the backoff from Lesson 29 takes its jitter as a function, so a test
asserts backoffDelay(3, noJitter) === 2_000.
Isolation: repository fakes and a clean state
A test that depends on what another test left behind fails in a different order tomorrow. The monthly report takes
a DayReader β one interface, one method, listDays(region) β so a test injects a fake, fresh for every test.
// src/reports/monthly.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { monthlyTotal, type DayReader } from "./monthly.js";
import type { ProductionDay } from "../domain/yield.js";
describe("monthlyTotal", () => {
let reader: DayReader;
beforeEach(() => {
const rows: ProductionDay[] = [{ date: "2026-03-14", region: "Dakar", kwh: 18, capacityKwp: 3 }];
reader = { listDays: async (region) => rows.filter((r) => r.region === region) }; // fresh state per test
});
it("sums only the region it was asked for", async () => {
await expect(monthlyTotal(reader, "Dakar")).resolves.toBe(18);
});
});
The fake takes two minutes to write and runs in microseconds; the :memory: SQLite database from Lesson 32 is the alternative when the query is what you test.
Boundaries, fixtures, coverage and the CI loop
Mock at the edge of the process, never in the middle. fetch, the file system and the clock are the three
boundaries worth faking, and the cheapest place is behind the adapter you already wrote and injected, so
vi.stubGlobal is needed only by the tests that exercise the adapter itself.
Recorded responses make good fixtures (./fixtures/power-march.json, read with readFile), and reviewed text
makes a good golden file: toMatchFileSnapshot("./__snapshots__/march-report.golden.txt") fails with a readable diff.
npx vitest run --coverage prints % Stmts, % Branch, % Funcs and % Lines per file and writes
coverage/index.html with the uncovered lines marked. Line coverage says which lines ran, never whether an
assertion was worth having; branch coverage is the number that matters for the discriminated unions from Lesson 7.
The CI loop from Lesson 41 gains one step β a workflow checking out with actions/checkout@v4, setting up Node 20
with actions/setup-node@v4, and running:
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
| Asserting on internals instead of behaviour | Every refactor breaks twenty tests, so the suite gets deleted | Assert what the caller observes: return values, thrown errors, written rows |
A forgotten await on an async assertion |
The test passes whatever the code does, because the check never finished | await expect(...).resolves or .rejects, or return the promise |
| Sharing state between tests | Failures that depend on order and vanish when you run one file | A fresh fake or :memory: database in beforeEach |
| Chasing 100% coverage | Tests that execute lines without asserting anything, and a number nobody reads | Cover the branches that carry decisions and let the number follow |
vi.useFakeTimers() with no reset |
A later file hangs on timers that never fire | vi.useRealTimers() in afterEach |
| Mocking your own modules | The suite is green while the real path is unwired | Inject fakes for your interfaces; mock only fetch, fs and the clock |
Try it
-
Add the config above and
"test": "vitest run", then runnpx vitest run. Expected:No test files foundand a non-zero exit code β a suite matching nothing is a failure, not a success. -
Add
src/domain/yield.tsand its test, then delete the guard so a zero capacity returnsInfinityand runnpx vitest run. Expected: one failing test printingInfinity, the others still green. Restore it. -
Add the fake-timer test and run
npx vitest run -t "aborts". Expected: one passing test in a few milliseconds; if it takes five seconds, the timers are real. -
Run
npx vitest run --coverage, opencoverage/index.html, write a test for an uncovered branch, then runnpx tsc -b. Expected: silence, which proves your test files typecheck too. Finally delete thevi.useRealTimers()line fromafterEachand run the suite twice: expected is a file that still passes while other files start to hang β an unrestored clock is a trap for the next file, not for this one.
Key takeaways
- A test is an executable claim about behaviour: it buys regression safety, documented intent and the licence to refactor β and proves nothing beyond the values you assert.
- Vitest transforms with esbuild and never typechecks, so
npx tsc -bandnpx vitest runare two gates and both must be green before you ship. - Time and randomness are inputs: fake the clock with
vi.useFakeTimers()plusadvanceTimersByTimeAsync, and pass jitter ornowin as parameters. - Isolation removes flakiness: a fresh fake or
:memory:database per test, and mocks only at the boundaries offetch, the file system and the clock. Coverage reports which lines ran, never whether the assertions mattered.
Check yourself
- Name one thing a green suite proves and two things it cannot prove.
expect(pending).rejects.toThrow()withoutawaitpasses on broken code: why, and what catches that mistake?- Which of these should be faked, and how: your repository interface,
fetch,Date.now?