Skip to content

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.

npm install --save-dev vitest @vitest/coverage-v8
// 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 expect before advancing time, or the rejection is unhandled and Node reports a crash. Use advanceTimersByTimeAsync rather 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:

npm ci && npm run typecheck && npx vitest run --coverage

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

  1. Add the config above and "test": "vitest run", then run npx vitest run. Expected: No test files found and a non-zero exit code β€” a suite matching nothing is a failure, not a success.

  2. Add src/domain/yield.ts and its test, then delete the guard so a zero capacity returns Infinity and run npx vitest run. Expected: one failing test printing Infinity, the others still green. Restore it.

  3. 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.

  4. Run npx vitest run --coverage, open coverage/index.html, write a test for an uncovered branch, then run npx tsc -b. Expected: silence, which proves your test files typecheck too. Finally delete the vi.useRealTimers() line from afterEach and 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 -b and npx vitest run are two gates and both must be green before you ship.
  • Time and randomness are inputs: fake the clock with vi.useFakeTimers() plus advanceTimersByTimeAsync, and pass jitter or now in as parameters.
  • Isolation removes flakiness: a fresh fake or :memory: database per test, and mocks only at the boundaries of fetch, the file system and the clock. Coverage reports which lines ran, never whether the assertions mattered.

Check yourself

  1. Name one thing a green suite proves and two things it cannot prove.
  2. expect(pending).rejects.toThrow() without await passes on broken code: why, and what catches that mistake?
  3. Which of these should be faked, and how: your repository interface, fetch, Date.now?

Next

Lesson 43 β€” Type-Safety Discipline and Common Traps