testing-techniques · git:20260906.55daf32 · 2026-09-06 · sha256 af5af12672b12897

testing-techniques git:20260906.55daf32A

Immutable. This exact content never changes and is served at /api/v1/blob/af5af12672b12897.

---
name: testing-techniques
description: The whiteboard repo's automated-testing technique catalogue — how to write a vitest test that stays green under a full parallel run (async assertions, fake timers, browser mode, mocks and isolation), how to prove a test is stable before pushing (repeats, stress, quarantine budget), how property-based and mutation testing close the loop on a test that asserts nothing, which flake shapes are already caught by an executable rung (GritQL plugin, setup-file guard, arch-lint scan) and how to add one, and the vitest features (including Vitest 5's: traceView, strict locators, vi.when, --repeats, expect.poll with AbortSignal, fsModuleCache, vitest doctor, the bench fixture) that serve each. Use when writing or reviewing a test, when a test flakes or times out, when a property or Stryker survivor needs triage, when configuring or running vitest, or when upgrading it. For WHICH layer a test belongs in, see test-layer-selection.
---

# Testing techniques (whiteboard)

`test-layer-selection` answers **where** a test goes. This skill answers **how to write one
that stays green** and **how to know it asserts something**. The body here is an index; the
detail is in `resources/*.md`, opened one at a time for the situation at hand — never all at
once.

## The rung ladder

Every technique here sits on a rung, and the rung is chosen by what would actually catch the
mistake — strongest first:

| Rung | Catches the mistake… | This repo's instruments |
|---|---|---|
| **executable** | at lint / typecheck / test time, mechanically | `tools/biome-plugins/test-flake-shapes.grit` (rules read from the file by its guard), `tools/arch-lint`'s test scans (lazy imports, duplicate titles, the sleep ledger), guard tests (`browser-test-name-length`, `vitest-data-dir`, `local-node-version`) |
| **setup guard** | at runtime, for every test in a project | `apps/web/vitest.setup.ts` (cleanup, fake-timer leak, localStorage), `browser-setup.ts` (stylesheet, async budget), `sharedBrowserTestConfig` (trace bounds) |
| **review criteria** | when a reviewer reads the diff | `review-gate/resources/test-coverage.md` |
| **prose** | only if someone remembers | `integrator-flow.md`'s CI-flakes section (the measurements), this skill |

A shape that costs a real defect twice earns a higher rung. How to move one up is
`resources/executable-rungs.md`.

The resources describe the INSTALLED vitest — the `vitest:` line of `pnpm-workspace.yaml`'s
catalog (5.0.0 since 2026-09-05). A config key the installed version does not know is
ignored silently, so check the version before relying on one. Features newer than a
reader's training carry their API and a snippet, beside the situation each serves — never
in a file named after a version, which only a reader who already knows the feature opens.

## Open the resource for the situation

| Situation | Resource |
|---|---|
| naming a test or a file, a duplicate title, a fixed sleep, a count or ordinal in a title | `resources/naming-and-structure.md` |
| async assertions, `expect.poll`, `waitFor`, fake timers, clocks, scheduler teardown | `resources/async-and-timers.md` |
| a `.browser.test.tsx`: focus, typing, locators, timeouts, titles, traces | `resources/browser-mode.md` |
| mocks, module state, storage, data dirs, workers, `--project` filters | `resources/isolation-and-state.md` |
| a property test, a Stryker survivor, a generator that reaches nothing, a coverage ledger | `resources/property-and-mutation.md` |
| "is this test stable?" — repeats, stress, isolation vs full run, quarantine, flake-watch | `resources/stability-checks.md` |
| adding a GritQL shape, a setup-file guard, or a source-scan test | `resources/executable-rungs.md` |
| projects and filters, pools and caches, `.vitest/` artifacts and reporters, benchmarks, custom matchers, coverage — and the four tempting options measured and REFUTED here | `resources/configuration.md` |
| moving to a new vitest major: breaking changes measured against this tree, and the order to land them | `resources/vitest-upgrade.md` |

## Write-time checklist (the shapes that cost the most, in one screen)

1. **`await` every `.resolves` / `.rejects` / `toMatchFileSnapshot` / `expect.element` /
   `expect.poll`.** Lint rejects all five (the `.not` form too); Vitest 5 fails the test.
2. **Nothing with a side effect inside `waitFor`.** A retried callback re-fires the action.
3. **Query inside the assertion**, never hold an element across an action that can remount it.
4. **Type ASCII** in browser tests; a keycode-less character is the one that drops under load.
5. **Restore what you change**: fake timers, `vi.stubEnv`, module mocks, storage. The jsdom
   setup fails the test that leaks fake timers, by name.
6. **Scope every assertion to a handle the test minted** — never a global counter or a "most
   recent" stream while another test's worker may still be alive.
7. **Static imports only** in a test file unless it mocks what it imports; an in-body
   `await import()` charges a module graph to the per-test timeout.
8. **A count proves the subject is present**: `expect(routes.length).toBeGreaterThan(N)` beside
   an allowlist walk, a reachability floor beside a property.
9. **A skip is probed, never inferred**, and must be impossible on CI.
10. **A title is an identifier**: behaviour, not chronology (no PR/issue number, no
    "pre-fix"), no count or ordinal of a registry that grows, unique within its `describe`.
11. **Wait for a condition, never for time**: `vi.waitFor` / `expect.poll` / fake timers, not
    `setTimeout(r, N)`. The per-file sleep ledger only goes down.
12. **Before pushing**: run the file 5× in fresh processes, then once inside the whole project
    — isolation proves nothing about the run that flakes.

## Commands

**A release note is a claim about someone else's repo.** Thirteen features from the 3.x, 4.x
and 5.0 announcements were checked against this tree and **three** were worth adopting —
`fsModuleCache`, in the one job a measurement said it pays; `vi.defineHelper`, on the four
helpers that assert; and a per-file `node` environment for the 145 jsdom files that never
touch a DOM (219s → 188s, and `happy-dom` refused at a further −16% because it breaks
prototype-level spying — `resources/configuration.md`). The other eleven were wrong for this repo: `isolate: false` (464
failures), `errorFormat: 'aria'` (already the default), `detectAsyncLeaks` (25 reports, all
one upstream artefact), the test `{ signal }` (nothing in `userEvent` accepts one),
`locators.extend`, `frameLocator`, `toBeInViewport`, the `agent` reporter, `schemaMatching`,
`sequence.groupOrder`, tags-as-quarantine, `coverage.autoAttachSubprocess` and
`injectCjsGlobals: false`. Measure on the projects that hold state, not the small pure ones;
`resources/configuration.md` carries each number.

```bash
pnpm test --project <name>              # nearest layer (names: test-layer-selection)
pnpm test:browser                       # the three real-browser projects
pnpm test:browser:replay                # + DOM replay of EVERY test in .vitest/index.html (~1MB per 100 tests)
pnpm test:browser:trace                 # + Playwright DOM snapshots — ONE failing file only (23GB otherwise)
for i in 1 2 3 4 5; do pnpm exec vitest run <file>; done   # what CI's stress step does
pnpm lint                               # includes the GritQL flake-shape plugin
pnpm test:scripts                       # plugin fixture guard + quarantine budget
pnpm mutation:contracts | pnpm mutation:render             # Stryker lanes
node .claude/scripts/flake-watch.mjs    # tests failing ≥2 main runs in 14 days
```