zudo-test-wisdom
GitHub repository

Type to search...

to open search from anywhere

Vitest Patterns

Unit, component, and build-output testing with Vitest -- workspace configurations, jsdom/happy-dom environments, contract testing, idempotency testing, and Miniflare integration testing.

Project-Level Vitest Configs

Large projects often need different Vitest configurations for different test types. Define multiple projects inside a single root config with test.projects:

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    projects: [
      {
        test: {
          name: "unit",
          include: ["src/**/*.test.ts"],
          environment: "node",
        },
      },
      {
        test: {
          name: "component",
          include: ["src/**/*.test.tsx"],
          environment: "jsdom",
        },
      },
      {
        test: {
          name: "build",
          include: ["tests/build/**/*.test.ts"],
          environment: "node",
        },
      },
    ],
  },
});

Tip

Separate projects let you run fast unit tests independently from slower component or build tests: vitest --project unit vs vitest --project component.

Per-Project Timeout Budgets

A common flake source: a test spawns a CLI with spawnSync/execSync and gives the child process a generous timeout: (say, 30 seconds), while Vitest's own per-test timeout defaults to 5000ms. Under host CPU contention the tighter deadline wins -- Vitest kills the test at 5s while the subprocess is still running well inside its own 30s budget. The failure reads like a hang ("Test timed out in 5000ms"), but nothing actually hung; the subprocess was merely slow under load and never got the chance to finish before the outer timeout fired.

Patching testTimeout per offending it() doesn't converge -- new subprocess-heavy tests keep landing in other files with the same 5s default, so the whack-a-mole never ends. Fix it at the config level instead: split test.projects so the subprocess-heavy directory gets a project-level testTimeout raised to at least 2x its largest child budget, while the pure-unit project keeps the strict 5s default as a guardrail against genuinely hung tests.

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  resolve: { alias: { /* root-level aliases */ } },
  test: {
    server: { deps: { inline: [/* runtime packages needing the alias pipeline */] } },
    projects: [
      {
        extends: true, // projects don't inherit the root config without this
        test: {
          name: "unit",
          include: ["src/**/__tests__/**/*.test.ts"],
          // default 5s testTimeout stays as the guardrail
        },
      },
      {
        extends: true,
        test: {
          name: "scripts",
          include: ["scripts/**/__tests__/**/*.test.ts"],
          // subprocess-heavy: budget 2x the largest 30s child timeout
          // open issue: https://github.com/your-org/your-app/issues/56
          testTimeout: 60_000,
        },
      },
    ],
  },
});

Tip

The pure-unit project's default testTimeout is doing real work here: raising it project-wide to accommodate the subprocess tests would also let a genuinely hung unit test run for 60 seconds instead of failing fast at 5.

Note

"Guardrail" means two different things across this guide. On this page it is runner-level: the strict default testTimeout whose whole job is to turn a genuinely hung test into a fast failure. In the deflaking recipe's "A fixed deadline as a failure guardrail on an already event-keyed wait" bullet -- the native-suite note under its Part 1 root-cause catalog -- it is per-wait: a fixed deadline layered on a wait that is already event-keyed, where the synchronization is correct and the only open question is whether that deadline's value is defensible.

Telling them apart matters because the standard deflaking prescription -- "replace the wall-clock wait with an event-keyed one" -- is a no-op against the second kind: there is no wall-clock wait left to replace, only a number to justify. What both senses do share is one discipline: raise either only from a derivation you can show -- either a measured latency distribution (baseline versus under load) or the child deadline the budget has to contain -- never on a hunch. The 60s project budget above meets the derivation requirement because it is derived from the largest child timeout it has to contain; a number picked to turn a red run green does not remove a flake, it hides one. Rule 8 — Never Game the Gate also requires a linked open issue and the same fresh-context review as any other edit to runner config, a gate-defining file.

extends: true Is Required

Vitest project configs do not inherit the root config by default. Add a project to test.projects without extends: true, and root-level resolve.alias and test.server.deps.inline silently stop applying to that project -- no error, no warning, the tests just run against unresolved dependencies. In a Preact-compat repo aliasing react to preact/compat, that surfaces as every test transitively importing a precompiled React-flavored runtime failing with Cannot find package 'react', because the alias that would have redirected the import never reached that project.

Both projects in the config above need extends: true for exactly this reason. Also drop the old root-level test.include once projects take over -- left in place, it collects test files independently of the projects and can double-run or mis-scope suites, so the project include arrays should be the only collectors.

Danger

Missing extends: true fails silently. There is no error pointing at the missing key -- only a downstream resolution failure inside the affected project's tests, which looks like a dependency problem, not a config problem.

See the Vitest projects configuration docs for the full inheritance rules.

Module-Eval Timeout Gap and Child-Process Timeout Hygiene

A subprocess call at module-eval time -- for example resolving git worktree list at import time, before any describe/it block runs -- escapes Vitest's per-test timeout entirely, because it executes during collection, not during a timed test run. If that call hangs, there is no 5-second (or 60-second project) backstop; it just hangs the whole collection phase before any test gets a chance to start. This is the same import-time-evaluation hazard as The module-eval timing trap elsewhere on this page, but for subprocess calls instead of DOM reads -- give it its own child-level timeout: explicitly, since the runner-level guardrail cannot reach it:

// setup-doc-skill.test.ts
import { execSync } from "node:child_process";

// Module-level eval runs before Vitest's per-test timeout applies, so this
// needs its own explicit child-level timeout.
const MAIN_WORKTREE_ROOT = execSync("git worktree list | head -1 | awk '{print $1}'", {
  cwd: PROJECT_ROOT,
  encoding: "utf-8",
  timeout: 30_000,
}).trim();

The same discipline applies inside every it(), not just at module scope: give every child-process call an explicit timeout: at or below the project's testTimeout, so a hung subprocess fails the individual test instead of silently eating the whole budget. And check whether the subprocess is doing real work at all -- a trivial shell-out like mkdir -p is pure filesystem I/O with no reason to fork a process:

// Before: forks a process for a one-line filesystem operation
import { execFileSync } from "node:child_process";
execFileSync("mkdir", ["-p", fakeBinDir]);

// After: same effect, in-process, nothing left to time out
import { mkdirSync } from "node:fs";
mkdirSync(fakeBinDir, { recursive: true });

Tip

Converting trivial subprocesses to in-process calls does double duty: it removes one more timeout to budget for, and it is faster, since spawning a process costs more than the filesystem call it wraps.

Cap Worker Parallelism in the Heavy Local Gate

Vitest runs test files across multiple worker processes by default, sized to the host's core count. On a busy laptop -- other tabs, other dev servers, a second pnpm build running in a neighboring worktree -- that many concurrent workers means every test is competing for CPU, which is exactly the contention that turns generous-looking timeouts flaky in the first place. Capping parallelism in the heavy pre-push gate trades wall-clock time for reliability:

# --maxWorkers=4 caps vitest parallelism for reliability under host CPU
# contention over wall-clock, not speed.
pnpm test:unit --maxWorkers=4

Note

Cap this only in the heavy local gate, not everywhere. Keep interactive pnpm test:unit uncapped for fast feedback during development, and leave CI runners alone -- they are typically low-core already and self-capping, so an explicit cap there just adds a needless ceiling. And when the cap is fed from an environment variable, validate the value at the boundary — Playwright's analogue, a workers value that coerces to NaN, runs zero tests and exits green.

Choosing pool and isolate Is a Measurement, Not a Default

pool (forks versus threads) and isolate (a fresh module graph per test file, or a shared one) are the two settings that change Vitest's execution model. They are routinely flipped together in a single "make the suite faster" commit -- which is exactly what makes the result unreadable. They fail in different ways, they are cleared by different evidence, and when a bundled change goes red you cannot tell which half broke it.

  • pool: "threads" runs test files in worker threads inside a shared process instead of separate forked processes. It is a native-code and runtime-compatibility question: any addon assuming one instance per process, or that is not thread-safe, is newly at risk.

  • isolate: false stops rebuilding the module graph for each test file. It is a correctness question: module-scope registries, caches, singletons, and patched globals now survive across files, so the suite becomes order-dependent.

Treat them as two experiments, run separately, each with its own measurement and its own rollback trigger. A suite can legitimately land on threads with isolation fully on, or on forks with isolation off -- pairing them carries no discount.

The Matched-Run Protocol

A pool or isolate comparison is evidence only if the two runs differ in exactly one thing. Pin everything else, and record the same metric set on both sides:

  • Same SHA, same selection. No rebase between runs, and byte-identical --project, shard, and filter arguments.

  • Fixed worker count on both sides. Left to the host's core count, you are measuring the machine's mood rather than the setting.

  • Exact collected-file and test-count parity. A candidate that is faster because it collected 40 fewer files is not a faster run, it is a broken selection. Check this before looking at any duration.

  • A duration breakdown, not a wall clock. transform / collect / setup / tests / environment / prepare -- Vitest already prints these, and only the breakdown says which phase the change actually moved.

  • Peak per-process heap and machine-wide RSS. Threads share one heap, so a per-process number can look flat while the host starts swapping. Measure both.

  • Retries and worker crashes. A run that passed on a retry, or that lost and restarted a worker, is not a clean run even when the summary is green.

  • State-leak and ordering evidence. For isolate: false specifically, a green run in the natural file order proves nothing. Re-run with the file order shuffled; if the result changes, the isolation you removed was load-bearing.

# Matched runs: same SHA, same selection, same fixed worker count.
# Keep the default reporter alongside json: the json file carries the
# per-file counts parity is diffed from, but the duration breakdown
# (transform / collect / setup / tests / environment / prepare) is printed
# only by the default reporter, so --reporter=json alone loses it.
COMMON="run --project unit --maxWorkers=4 --reporter=default --reporter=json"

/usr/bin/time -l pnpm vitest $COMMON --outputFile.json=baseline.json
/usr/bin/time -l pnpm vitest $COMMON --outputFile.json=candidate.json --pool=threads

# /usr/bin/time -l (macOS) or -v (GNU) reports the peak RSS of the LARGEST
# SINGLE process in the tree, never the sum -- four forks at 1 GB each read
# as 1 GB. Machine-wide pressure has to be sampled alongside the run
# (`vm_stat 5` on macOS, `vmstat 5` on Linux), watching for swap activity.

# Parity gate FIRST. A faster run that collected fewer files is not faster.
jq '.testResults | length' baseline.json candidate.json
jq '.numTotalTests' baseline.json candidate.json

# Ordering evidence -- for the isolate experiment only.
pnpm vitest $COMMON --outputFile.json=shuffled.json --no-isolate --sequence.shuffle.files

Warning

Check parity before duration, every time. Selection drift is the easiest way for a pool or isolate experiment to look like a win: the candidate config silently collects a smaller set, the wall clock drops, and the number gets reported as a speedup. The file count and the test count are the gate; the timings mean something only behind it.

Inventory Native Addons and Environment Overlap Before Choosing threads

Before running the threads experiment at all, enumerate what the suite actually loads. Never infer incompatibility -- or safety -- from package presence alone. A native addon listed in package.json may be loaded by no test in the current selection, and one that is absent from it can still arrive transitively through a dependency. The real question is which files under test import it, and whether those same files also carry a DOM environment.

A measured inventory from a 1,062-file suite makes the point. 76 test files imported native canvas directly, 101 touched createCanvas, and 8 more loaded sharp -- yet none of the direct-canvas or createCanvas files overlapped the set carrying a jsdom environment pragma. The commonly assumed jsdom-plus-canvas interaction was not the mechanism at all; the risk lived in a different, larger set of files than the folklore predicted. Guessing would have produced the wrong experiment.

# Which files actually load a native addon, and whether those same files
# carry a DOM environment. package.json presence proves neither risk nor safety.
rg -l "from ['\"]canvas['\"]" src tests | sort > canvas-direct.txt
rg -l "createCanvas" src tests | sort > canvas-any.txt
rg -l "from ['\"]sharp['\"]" src tests | sort > sharp.txt
rg -l "@vitest-environment jsdom" src tests | sort > jsdom.txt

# The overlap is the actual question -- neither list answers it alone.
comm -12 canvas-any.txt jsdom.txt

Note

An empty overlap is a finding, not a green light. It tells you the failure you were bracing for is not the one you have; the addon files still run somewhere, and the threads experiment still has to prove they survive a shared process.

Rollback Triggers, Fixed Before the Run

Decide what ends the experiment before starting it, so the call is not made under sunk-cost pressure at 2am with a half-migrated config:

  • A worker crash or segfault -- once is enough, even if the retry passed.

  • The host swapping or going unresponsive -- read this off the machine-wide RSS, not the per-process heap.

  • Collection drift: the candidate collects a different set of test files.

  • Test-count drift: the same files yield a different number of tests.

  • An order-dependent failure: the shuffled run disagrees with the natural-order run.

Any one of them reverts the setting. None of them is a debug-it-in-place situation -- revert first, then investigate on a branch, because an execution-model change already known to be wrong for one reason is not a stable base for finding the second.

Where the Setting Applies -- and What Else Reads It

pool and isolate can be set at the root or per project, and a project inherits neither without extends: true -- the same silent-inheritance rule covered in extends: true Is Required. It bites harder here than elsewhere: a root-level pool: "threads" added to a projects-based config without extends: true changes nothing, and the experiment reports "no regression" because it never ran. Scoping the experiment to one project is also the safest way to run it -- keep the native-addon files on forks while a single project tries threads.

// vitest.config.ts -- pool/isolate are per-project levers, and a project
// inherits nothing from the root without `extends: true`.
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    pool: "forks",
    projects: [
      {
        extends: true,
        test: {
          name: "unit",
          include: ["src/**/__tests__/**/*.test.ts"],
          environment: "jsdom",
        },
      },
      {
        extends: true,
        test: {
          name: "native",
          include: ["src/**/__tests__/**/*.native.test.ts"],
          // Native-addon files stay on forks with isolation on, even while
          // `unit` is the threads experiment. One lever, one project.
          pool: "forks",
          isolate: true,
        },
      },
    ],
  },
});

Moving include or environment under projects carries a second cost that is easy to miss: any tooling that parses the config statically goes blind. A CI guard asserting that a top-level test.include covers every test directory, a script reading test.environment to decide what to install, a coverage config enumerating the same globs -- each keeps reading a top-level key that no longer holds the real value, and each keeps passing while doing so. Updating those readers is part of the config change, not follow-up work.

Danger

A guard reading a key that has moved does not fail -- it silently succeeds against a stale or empty value. That is worse than a broken guard, because the dashboard stays green. Grep for every reader of test.include and test.environment in your own scripts and CI workflows before landing a projects restructure.

Runner Shape Is Not Execution Model

Not every "the suite is slow" fix needs this protocol. Removing a repeated collection pass, or capping workers as above, changes the shape of the run -- how many times the work is done, and how much of it runs at once -- while every individual test file executes exactly as before. Those are cheap to reason about and cheap to revert. pool and isolate change how your code runs, and each costs a measured experiment.

Which kind you need is a question the numbers answer. A six-shard CI baseline on 2-vCPU runners (Vitest 3.2.4, 30,228 selected tests across 1,062 test files) broke down like this per shard:

  • collect: 57.2 s to 78.9 s

  • environment: 18.6 s to 22.1 s

  • setup: 0 ms on every shard

Collection, not environment setup, was the dominant fixed cost -- and neither pool nor isolate addresses collection. The popular candidates were aimed at the wrong number: swapping the DOM environment targets a cost roughly a third the size, and isolate: false would have stripped file isolation from 1,062 files full of module-scope registries and caches, buying an order-and-state correctness risk the profile never asked for. What the profile did justify was deleting a repeated local shard loop that re-paid the whole collection cost for every shard -- a runner-shape change, with pool, isolate, and the CI matrix all left exactly as they were.

Tip

Change one execution-model lever at a time, and be willing to defer both. "We measured, the evidence pointed at collection, so we deferred pool selection and disabled isolation" is a complete and successful outcome -- not an unfinished optimization.

jsdom and happy-dom Environments

Choose the right DOM environment for component tests:

// vitest.config.ts for component tests
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
    // Or use happy-dom for faster execution:
    // environment: "happy-dom",
    globals: true,
    setupFiles: ["./test-setup.ts"],
  },
});

Per-file environment override when needed:

// @vitest-environment jsdom
import { describe, it, expect } from "vitest";

describe("DOM-dependent test", () => {
  it("manipulates the document", () => {
    document.body.innerHTML = '<div id="app">Hello</div>';
    expect(document.getElementById("app")?.textContent).toBe("Hello");
  });
});

Vitest Browser Mode: Two-Project Split

Component tests that need to assert a real computed style -- var() and oklch() resolution, actual layout values -- outgrow jsdom's synthetic CSS engine. Vitest's browser mode runs a project's tests inside a real Playwright-driven browser instead, using the same test.projects mechanism as above: keep the fast jsdom unit project for everything that doesn't touch CSS, and add a browser project scoped to a *.browser.test.ts naming convention for the handful of tests that do.

// vitest.config.ts -- current Vitest 3.x/4.x browser-mode shape
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    projects: [
      {
        test: {
          name: "unit",
          include: ["src/**/*.test.ts"],
          environment: "jsdom",
        },
      },
      {
        test: {
          name: "browser",
          include: ["src/**/*.browser.test.ts"],
          browser: {
            enabled: true,
            provider: "playwright",
            instances: [{ browser: "chromium" }],
          },
        },
      },
    ],
  },
});
pnpm add -D vitest @vitest/browser playwright

Tip

Run just the browser project with pnpm vitest run --project browser. The *.browser.test.ts suffix is the only thing routing a file into real Chromium instead of jsdom -- keep the naming convention consistent.

Note

This is the config-level half of When jsdom Is Not Enough -- same Level 2 ergonomics (component isolation, the vitest API), Level 5-grade CSS observability for library code under test.

Separate Configs for Different Test Types

A pattern from mdx-formatter, a Markdown/MDX formatter (Node CLI + npm library over a Rust engine): separate configurations for unit tests, API tests, and function tests:

vitest.config.ts          # Default: unit tests
vitest.config.api.ts      # API integration tests
vitest.config.functions.ts # Cloud function tests
{
  "scripts": {
    "test": "vitest",
    "test:api": "vitest --config vitest.config.api.ts",
    "test:functions": "vitest --config vitest.config.functions.ts",
    "test:all": "vitest && vitest --config vitest.config.api.ts"
  }
}

Contract Testing: Rust Engine via Vitest

From mdx-formatter: the Vitest suite serves as a contract test for the Rust formatting engine. The Node.js wrapper calls the Rust binary, and Vitest verifies the output matches expectations:

// tests/contract.test.ts
import { describe, it, expect } from "vitest";
import { execSync } from "child_process";

describe("Rust formatter contract", () => {
  it("formats basic MDX correctly", () => {
    const input = "# Hello\nSome   text   here";
    const result = execSync(`echo '${input}' | ./target/release/formatter`, {
      encoding: "utf-8",
    });
    expect(result.trim()).toBe("# Hello\n\nSome text here");
  });
});

Note

Contract testing lets you verify a binary's behavior from a higher-level language. The Vitest suite acts as the specification -- if the Rust engine changes behavior, the contract tests catch it.

Idempotency Testing

A powerful invariant for formatters and transformers: applying the operation twice should produce the same result as applying it once.

// tests/idempotency.test.ts
import { describe, it, expect } from "vitest";
import { format } from "../src/format";
import { readFileSync, readdirSync } from "fs";
import { join } from "path";

const FIXTURES_DIR = join(__dirname, "fixtures");

describe("idempotency", () => {
  const fixtures = readdirSync(FIXTURES_DIR).filter((f) =>
    f.endsWith(".mdx")
  );

  for (const fixture of fixtures) {
    it(`is idempotent for ${fixture}`, () => {
      const input = readFileSync(join(FIXTURES_DIR, fixture), "utf-8");
      const firstPass = format(input);
      const secondPass = format(firstPass);
      expect(firstPass).toBe(secondPass);
    });
  }
});

Miniflare + D1/R2 Integration Tests

From pgen (a private GPU pattern generator with a Cloudflare Workers backend): testing Cloudflare Workers with local D1 database and R2 storage using Miniflare:

// tests/integration.test.ts
import { describe, it, expect, beforeAll } from "vitest";
import { Miniflare } from "miniflare";

describe("Worker with D1", () => {
  let mf: Miniflare;

  beforeAll(async () => {
    mf = new Miniflare({
      modules: true,
      script: `export default { async fetch(req, env) { /* ... */ } }`,
      d1Databases: ["DB"],
      r2Buckets: ["STORAGE"],
    });

    // Run migrations. D1's exec() splits its input on newlines (one
    // statement per line), so a pretty-printed multi-line CREATE TABLE
    // throws D1_EXEC_ERROR -- keep the statement on a single line.
    const db = await mf.getD1Database("DB");
    await db.exec(
      "CREATE TABLE IF NOT EXISTS patterns (id TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL)",
    );
  });

  it("stores and retrieves a pattern", async () => {
    const resp = await mf.dispatchFetch("http://localhost/api/patterns", {
      method: "POST",
      body: JSON.stringify({ name: "test", data: "{}" }),
    });
    expect(resp.status).toBe(201);

    const getResp = await mf.dispatchFetch("http://localhost/api/patterns");
    const patterns = await getResp.json();
    expect(patterns).toHaveLength(1);
    expect(patterns[0].name).toBe("test");
  });
});

Warning

D1's exec() splits on newlines, not semicolons. Each line of the input string is executed as a separate statement, so a SQL statement pretty-printed across multiple lines throws D1_EXEC_ERROR: incomplete input -- D1 tries to run each fragment on its own. Collapse each statement to a single line before calling exec(), or split a multi-statement migration file yourself (see createTestEnv for the multi-statement case). This is a platform quirk, not something recoverable by reading more of your own code.

Tip

Miniflare runs the same Workers runtime locally, so integration tests closely match production behavior. Combined with D1 and R2 bindings, you can test full data flows without deploying.

happy-dom Gotchas

happy-dom is fast, but it has a handful of behaviors you cannot recover by reading more of your own code -- they are framework quirks. From zfb-runtime's client-router suite -- a package of zfb, the Rust-based frontend build orchestrator this site runs on -- here are the escape hatches and the one import-order trap that cost the most time to discover.

Disable real file loading with a shim

When a test injects <link rel="stylesheet"> or <script src> tags, happy-dom will try to perform real network fetches. Disable them up front so tests stay hermetic, and treat the disabled loads as silent successes so the load events still fire:

// _helpers.ts
type HappyDOMWindow = Window & {
  happyDOM: {
    settings: Record<string, boolean>;
    waitUntilComplete: () => Promise<void>;
  };
};

export function installHappyDomShim(): void {
  const w = window as unknown as HappyDOMWindow;
  w.happyDOM.settings["disableJavaScriptFileLoading"] = true;
  w.happyDOM.settings["disableCSSFileLoading"] = true;
  w.happyDOM.settings["disableIframePageLoading"] = true;
  w.happyDOM.settings["handleDisabledFileLoadingAsSuccess"] = true;
}

Warning

Call installHappyDomShim() at module top, before any DOM mutation in describe/beforeEach. The settings must be in place before the first tag insertion, otherwise the very first fetch escapes.

Drain pending async tasks in afterEach

Even with file loading disabled, happy-dom still queues async work (for example, silent stylesheet load events). If that work is still pending when Vitest tears the environment down, you get flaky teardown errors that have nothing to do with your assertions. Drain it explicitly:

// _helpers.ts
export async function drainHappyDom(): Promise<void> {
  const w = window as unknown as HappyDOMWindow;
  await w.happyDOM.waitUntilComplete();
}
// router.test.ts
afterEach(async () => {
  await drainHappyDom();
});

Reset the document through the real DOM API

A natural reset between tests is document.body.innerHTML = "". But if a prior test swapped the body element via replaceWith(), the shortcut leaves getElementById internals stale -- lookups start returning the wrong nodes. Rebuild the body through the standard DOM API instead:

// _helpers.ts
export function resetDocument(): void {
  document.head.innerHTML = "";
  if (document.body) document.body.remove();
  document.documentElement.appendChild(document.createElement("body"));
}

Note

Recreating the element with createElement("body") + appendChild resets happy-dom's internal node registry cleanly, which innerHTML = "" does not when the body was previously replaced.

The module-eval timing trap (the key insight)

This is the one that is genuinely impossible to reason out from the test alone. Some modules read the live document at module-eval time -- the moment they are first imported, not inside a function you call later. The client router checks for an opt-in <meta> tag at its top level. If you import the router before injecting that tag, the router has already decided the page is not opted in, and no amount of later DOM setup fixes it.

The fix is to inject the tag first, then import the module under test:

// router.test.ts
import { drainHappyDom, installHappyDomShim, resetDocument } from "./_helpers.js";

installHappyDomShim();

// Inject the opt-in meta tag BEFORE importing the router module, so the
// module's top-level branch sees the page as opted-in.
// router.ts reads the live document at module-eval time.
function enableTransitions(): void {
  const meta = document.createElement("meta");
  meta.setAttribute("name", "zfb-view-transitions-enabled");
  meta.setAttribute("content", "true");
  document.head.appendChild(meta);
}
enableTransitions();

// Dynamic import AFTER the document is primed. A static `import` here
// would hoist above enableTransitions(), so the router would still see
// the pre-injection document -- `await import(...)` runs at this exact
// point in the file instead.
const { init, navigate } = await import("../../client-router/router.js");

Danger

If the module under test reads document (or any global) at eval time, a top-of-file static import runs that code immediately. Order your setup before the import, or switch to a dynamic await import(...) placed after the document is primed. ESM hoists static imports above surrounding statements, so a plain import will not observe DOM mutations written above it in the file unless those mutations are themselves in earlier-evaluated modules.

Bonus: mock startViewTransition to test both paths

document.startViewTransition is a progressive-enhancement API. happy-dom does not implement it, and you want to cover both the View-Transition path and the plain fallback. Assign the method directly on document rather than stubbing the whole global -- vi.stubGlobal("document", { ...document, ... }) spreads document into a plain object literal, which loses every prototype-based DOM method (createElement, getElementById, and so on), breaking anything else the test touches:

// router.test.ts
afterEach(() => {
  delete (document as any).startViewTransition;
});

it("uses the View Transitions path when available", async () => {
  (document as any).startViewTransition = vi.fn((cb: () => void) => {
    cb();
    return { finished: Promise.resolve(), ready: Promise.resolve() };
  });
  // ...drive navigate() and assert the transition branch ran
});

it("falls back to a plain DOM swap when the API is absent", async () => {
  // happy-dom has no startViewTransition by default -- assert the fallback
  // ...drive navigate() and assert no transition was started
});

Aliasing a Precompiled Runtime (React → Preact)

A Preact/compat project that tests against a precompiled island runtime -- or any dist authored against React -- hits three walls in a row. None of them are recoverable by reading your own code; each lives in how vite and the package manager resolve the other side.

Wall 1: the bare react alias swallows react/jsx-runtime

Symptom: Failed to resolve import "react/jsx-runtime" (or jsx-dev-runtime) even though react is aliased. In array form, vite's resolve.alias is first-match-wins -- order the entries most-specific-first, and note the jsx runtimes map to Preact's own jsx runtimes (preact/jsx-runtime, preact/jsx-dev-runtime), not preact/compat:

// vitest.config.ts -- resolve.alias is TOP-LEVEL vite config, not under `test`
export default defineConfig({
  resolve: {
    alias: [
      { find: "react/jsx-runtime", replacement: "preact/jsx-runtime" },
      { find: "react/jsx-dev-runtime", replacement: "preact/jsx-dev-runtime" },
      { find: "react-dom", replacement: "preact/compat" },
      // exact-match regex: immune to reordering, never swallows subpaths
      { find: /^react$/, replacement: "preact/compat" },
    ],
  },
});

Wall 2: aliases are silently ignored for externalized deps

Symptom: the aliases above work for your own source, but the precompiled dependency still loads the real React -- with no error. This is the trickiest of the three. Vitest externalizes node_modules deps by default, and externalized modules load through Node's native resolver, which bypasses vite's resolve.alias entirely. Force the one dependency through vite's transform pipeline with server.deps.inline -- this option lives under test:

export default defineConfig({
  test: {
    server: {
      deps: {
        // Inline ONLY the precompiled package (plus at most its small
        // runtime deps). Inlining everything slows the run and masks
        // other resolution issues.
        inline: ["@acme/island-runtime"],
      },
    },
  },
});

Wall 3: pinned pnpm store paths rot

Symptom: a test reaches a hoisted-but-unsurfaced peer (say, preact-render-to-string pulled in by the runtime) via a hard-coded node_modules/.pnpm/preact-render-to-string@6.5.0_preact@10.22.0/... path -- and the path breaks on every pnpm up preact, because the version-and-peer hash is part of the directory name. Scan the store dynamically instead (pnpm-specific):

import { readdirSync } from "node:fs";
import path from "node:path";

// Scoped packages encode as "@scope+name@version" in .pnpm --
// adjust the prefix accordingly.
const findPnpmDir = (pkgName: string) => {
  const store = path.join(process.cwd(), "node_modules", ".pnpm");
  const hit = readdirSync(store).find((d) => d.startsWith(`${pkgName}@`));
  if (!hit) throw new Error(`no .pnpm entry for ${pkgName}`);
  return path.join(store, hit, "node_modules", pkgName);
};

// Resolves whatever version is currently installed:
const rtsDir = findPnpmDir("preact-render-to-string");

Tip

Prefer declaring the missing peer as a real dependency when you can -- then a plain import works and no scan is needed. The dynamic scan is for the narrower case of testing an installed artifact whose peer is hoisted but unsurfaced, where adding the dependency would misrepresent the package's real contract.

Guard the Single Source of Truth

When one constant lives in a single source file but is consumed through two different import chains -- say a TypeScript ESM entry and a hand-written .mjs bin -- the two copies can silently fork if either chain breaks. A tiny "meta" test pins them together. From zfb-adapter-cloudflare, the worker wrapper string is imported once from src/build.ts and once via re-export from bin/cli.mjs, and a single assertion guards that they never diverge:

// cli.test.ts
import { WORKER_WRAPPER_SOURCE as TS_WRAPPER } from "../build.js";
// CLI helper is a sibling .mjs re-exporting the same canonical constant.
import { WORKER_WRAPPER_SOURCE as MJS_WRAPPER } from "../../bin/cli.mjs";

describe("single source of truth", () => {
  it("the .mjs bin re-exports the same wrapper as build.ts", () => {
    // Both ultimately import from the canonical src/worker-wrapper.mjs,
    // so they must be byte-identical. This catches import-chain breakage.
    expect(MJS_WRAPPER).toBe(TS_WRAPPER);
  });
});

Tip

Generalize the pattern: any value shared between two consumers that should be one thing can fork silently. One toBe catches it. The same shape applies to "this TypeScript enum must match that JSON config," or "this .d.ts declaration must match the runtime export." Whenever a value is duplicated across a module boundary -- or between a generated file and its source -- a single equality assertion turns a silent divergence into a failing test.

This guard catches in-repo divergence. It does not catch divergence between your parser and an external tool's real output schema -- that case needs a captured-artifact fixture rather than a hand-authored one; see Pin Reporter-Parsing Fixtures to a Real Captured Report. And when either side of the pin is read out of dist/ rather than out of source, the pin only holds if nothing else can rewrite that artifact mid-run -- see A Built Artifact With More Than One Writer Is Shared Mutable State.

The Mirror-Test Anti-Pattern

The guard above pins duplicated values. Functions can fork the same way -- and the failure mode has a distinctive shape: a test file re-implements the function under test instead of importing it, sometimes announcing itself with a comment like "we re-implement the helper here rather than importing". Such a mirror test passes forever even when the real implementation regresses, because it locks the hand-copied mirror, not production code.

Observed in the wild:

  • A suite mirroring helpers from a source file that was itself dead code -- no production importer existed, so the tests locked nothing at all.

  • A mirrored copy citing a source path that no longer existed after a refactor.

  • Two sibling scripts -- a CI guard and a telemetry reporter -- each carrying a private copy of the same URL-extraction function, with only one copy unit-tested, yet both must agree for the pipeline to work.

The pattern emerges for a mundane reason: the real function is module-private, lives inside an inline script string, or sits in a file with top-level CLI side effects. Importing it is inconvenient, so copying feels harmless.

Never re-implement the function under test. If it is not importable, that is the bug to fix first: extract it into an exported pure module and import it from both production code and the test.

// mytool.test.ts -- BEFORE (the anti-pattern)
// re-implemented here for testability   <- review red flag, not a justification
const extractIssueUrl = (line: string) =>
  line.match(/https:\/\/github\.com\/\S+/)?.[0] ?? null;

// AFTER: one canonical module, imported by production and tests alike
import { extractIssueUrl } from "../scripts/lib/extract-issue-url.js";

When extraction is genuinely disproportionate -- say the function lives inside a built IIFE string -- fall back to the guard above, generalized to functions: a build-time pin that extracts the embedded source and compares it against the canonical module, so divergence fails loudly. Compare source text against source text (normalizing line endings); do not pin against fn.toString(), whose output shifts with formatting and transpilation.

// Meta-test: it guards duplicate-implementation drift, nothing more.
// Behavioral tests still target the canonical module directly.
const normalize = (s: string) => s.replace(/\r\n/g, "\n").trim();

it("the built wrapper embeds the canonical parseRoute source", () => {
  const built = normalize(readFileSync("dist/worker-wrapper.mjs", "utf8"));
  const canonical = normalize(readFileSync("src/parse-route.mjs", "utf8"));
  expect(built).toContain(canonical);
});

Two corollaries close the loop:

  • A "re-implemented here for testability" comment is a review tell. Treat it as a red flag to fix, never as a justification to accept.

  • Before extracting, check the mirrored source is even alive. A mirror of dead code means the tests locked nothing -- retarget them at the live implementation, or delete them.

Tip

The mirror test is the SSoT guard's failure case, inverted: where the guard pins two copies together so they cannot fork, the mirror test creates a second copy with no pin at all. If you find yourself copying a function into a test file, either import it (extracting first if needed) or pin it -- never free-float the copy.

Generator/Host Lockstep Tests

Guard the Single Source of Truth above pins one duplicated value. The same risk scales up whenever a project ships a generator or scaffolder that must track a live host implementation -- the generator's copy of a feature list, an option set, or a whole CLI surface can drift from the host's real exports one release at a time, and nothing fails until a generated project breaks in someone else's hands. Two test shapes close that gap, and they are the test-level complement of a shell-based template-drift guard (see the companion note below).

Roundtrip Tests: Produce, Re-Parse, Compare

Import both sides of the boundary in the same test: the host's live configuration logic, and the generator's real CLI parser (not a hand-copied stub of either). Build a CLI command from live host state, re-parse the command the same way a user's shell would, and assert the roundtrip lands back on the source-of-truth values:

// tests/roundtrip.test.ts
import { describe, it, expect } from "vitest";
import { buildFeatureFlags } from "../../host/src/config/feature-flags.js";
import { buildCliCommand } from "../src/generator.js";
import { parseCliArgs } from "../src/cli-parser.js";

describe("generator roundtrip", () => {
  it("re-parsing a generated command reproduces the host's live flags", () => {
    // Source of truth: the host's real config function, not a copied list.
    const sourceOfTruth = buildFeatureFlags({ tier: "pro" });

    const command = buildCliCommand(sourceOfTruth);
    const reparsed = parseCliArgs(command);

    expect(reparsed).toEqual(sourceOfTruth);
  });
});

Tip

The test imports the generator's real parser, not a mock. A roundtrip test that reimplements parsing to make the assertion pass is the mirror-test anti-pattern above, wearing a different hat -- it locks the hand-copied parser, not the one users actually run.

Sync-List Tests: Pin Enumerations Across the Package Boundary

Roundtrip tests catch a broken conversion. They do not catch an enumeration -- a feature list, a template registry, an option set -- that the generator simply forgot to update. For that, assert the generator's list equals the host's live export directly:

// tests/sync-list.test.ts
import { describe, it, expect } from "vitest";
import { AVAILABLE_TEMPLATES } from "../../host/src/config/templates.js";
import { GENERATOR_TEMPLATE_CHOICES } from "../src/template-choices.js";

describe("generator/host template sync", () => {
  it("the generator offers exactly the host's live template list", () => {
    // Whole configuration surface, not a single constant -- this is the
    // SSoT guard generalized across a package boundary.
    const hostIds = AVAILABLE_TEMPLATES.map((t) => t.id).sort();
    const generatorIds = GENERATOR_TEMPLATE_CHOICES.map((c) => c.id).sort();

    expect(generatorIds).toEqual(hostIds);
  });
});

Warning

Do not exempt template file copies from drift-checking with a blanket allowlist. An allowlist only proves the files it lists were once acknowledged as copies -- it says nothing about whether today's copy still matches today's source, and new copies added later inherit the exemption for free. Prefer a normalized-diff check (strip only the values expected to legitimately vary, such as a package name or version) or, stronger still, running the template's own test suite against the installed package. Either one fails when the copy actually drifts; a blanket allowlist never does.

Revision History

CreatedUpdated