Testing Conventions (Vitest)
SkillFiles & storageEsposter Vitest testing conventions — a test file colocated with what it tests, describe with function refs, constants scoped to the describe block rather than module scope, test.each over loops, always typing vi.fn, toStrictEqual, takeOne/assert.exists, no unnecessary destructure, call-count matchers, toThrowErrorMatchingInlineSnapshot as the only error assertion, the polling ban, the ban on running the full suite locally, and what earns a test at all — plus deep dives on canonical test values and the shared-test-data DRY rule, router tests, what to mock plus mock cleanup and global/env stubs, the nuxt environment, platform/CLI/bundle snapshots, full-run failures, helper/`.test-d.ts` files, fake timers with hand-resolved promises, error-snapshot reconstruction, fixture shapes, and which subjects earn a test. Apply when writing .test.ts or .test-d.ts files.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Testing Conventions (Vitest) skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/testing/SKILL.md and read by ahel’s review.
Deep dives
references/router-test-setup.md— tRPC callers, mock sessions, seeded mock-DB rows, naming a router test.references/test-data.md— choosing the values a test asserts on, or giving two suites the same fixture.references/module-mocks.md— what to mock; colocated doubles,vi.mockfactories, thedbgetter, client tRPC calls, gating a double to prove a caller awaits it, and which cleanup hook the mock's creation style demands.references/error-assertions.md— filling in the inline snapshot a thrown or rejected error is asserted with.references/what-earns-a-test.md— deciding whether a given subject earns a test at all, and which one.references/shared-test-data.md— a value two tests need, an envelope built per case, or a literal production already owns.references/nuxt-environment-and-mounting.md— a DOM, the nuxt runtime, a mounted component, a routed link, a dispatched event.references/platform-and-bundle-tests.md— skipping on some hosts, colorized CLI output, a builtdistsize.references/test-helper-files.md— anything that isn't a plain suite: shared helpers,constants.test.tsfixtures, filesystem path names, a wrapper suite delegating its matrix,.test-d.ts.references/running-the-suite.md— reading a CI failure or timeout that only the full parallel run produces, and the Windows module allowlist.references/timers-and-hand-resolved-promises.md— fake timers, a pinned clock, throttled code, or a call held in flight.
Structure
testnotit— alwaystest(...).- A test lives beside what it tests.
Foo.ts→Foo.test.tsin the same folder — never folded into a larger nearby suite whose fixtures happen to be set up already, and never moved to whatever module the check happens to scan: a test that reads the whole repo still belongs beside the thing it proves something about. Two checks walking the same directory are two files when they prove different things — one that scans a tree tests the tree, one that asserts a map's contents tests the map. The cost of folding is that nobody openingFoo.tscan tell it is covered. describe(functionRef, …)— the function reference itself; a string only when no importable reference exists, and then it names the file's export, never the topic the test happens to cover (describe("drizzleAdapterConfiguration"), notdescribe("better-auth joins")). The title is camelCase whatever the export's own casing is — a PascalCase constant is titled with its first letter lowered (describe("achievementDefinitionMap")overAchievementDefinitionMap), which is what keeps every suite title in the repo one shape rather than splitting on what kind of thing each file happens to export. A SCREAMING_SNAKE_CASE export is camelCased whole —NON_SOURCE_SUFFIXESisnonSourceSuffixes, nevernON_SOURCE_SUFFIXES, which is what lowering only the first letter produces.vitest/prefer-lowercase-titlerejects the verbatim name outright, so this is enforced rather than preferred. Never pass the constant itself where its value is a string —describe(SOURCE_CONDITION)titles the suite with the value, so nobody grepping the name finds it. What the test proves belongs in the test name; the block names what is under test. Flat — never a nesteddescribefor sub-grouping.- Nothing but imports, pure helper functions and hoisted mocks lives at module scope. Every constant — a literal, a fixture object, an entity built by a factory — is a
constinside thedescribecallback. The reason is reachability, not memory: both are created during collection and freed at the same teardown, but a binding a sibling suite can reach is one a sibling suite can mutate, which is how a suite becomes order-dependent. The exceptions are what cannot move inward — thevi.hoistedblock, whichvi.mocklifts above the imports, and anything avi.mockfactory closes over (thelet mockDbaget db()factory returns). A helper that captures a suite constant is not the pure kind and moves in with it — but pure here means it holds no binding a sibling suite can reach, never that it has no effects: a helper that stubs a global and builds its captured state per call is stateless in the sense the rule is about, andunicorn/consistent-function-scopingputs it at module scope for you, because it closes over nothing. A constant shared by siblingdescribes is declared in each, because duplicating two lines beats a file-scope binding every block can reach.describe.eachis the one case where the scoping is also a lifetime — its callback runs per case. State rebuilt per test is aletin the same place, initialized inbeforeEach(references/shared-test-data.md). test.eachfor a table of cases, never a loop aroundtest(vitest/prefer-each) — a loop registers every case under one name, sopnpm test -tcannot select one. The title takes%srather than a template literal, which is what makes the row title match the case; a table of enum members needsas const, or the array widens to the enum and any discriminated union the case feeds rejects it.expect.hasAssertions()— top of every test body.- Assertions after all assignments —
expectcalls follow that phase's operations and locals, after a blank line. - A
voidreturn is never assigned or asserted at runtime (no-confusing-void-expression, caught by the rootpnpm lintalone sinceapps/web's ESLint isn't type-aware; never disabled). APromise<void>:await fn();bare when another assertion follows, elseawait expect(fn()).resolves.toBeUndefined();. One resolving to a real value goes into aconst; a syncvoidcontract is asserted in a.test-d.ts(references/test-helper-files.md). - Reuse utilities, and prefix factories
create*— look for an existing helper beside the code under test first; builders arecreateRow, nevermake*.
Test data — references/test-data.md
Values are canonical and minimal, and shared fixtures have one home. Choosing the values a test asserts on, or giving two suites the same fixture, is that page.
Assertions
toStrictEqualalways — nevertoEqual/toMatchObject, which pass while the fields you did not name drift; assert each field the test is about, or the whole value.toStrictEqual(expect.objectContaining(...))istoMatchObjectwearing a longer name and goes the same way.expect.arrayContainingis the one with a real use — a genuine superset, where the extra elements are not the test's business — but never for an argv or an ordered sequence: it checks each element independently, so a run of flags passes it while scattered across the array and paired with the wrong values, which for an argv is the whole meaning. Snapshot the array instead; the snapshot then subsumes theindexOfordering assertions written to shore the fragment up, and the comment saying why the order matters is what survives. Assert exact counts: no.toBeGreaterThan(0)on collections. The one carve-out is a counter aggregated over machinery the test is not about — a per-finder tally, a count of a stats object's own fields — where the exact number is that machinery's size and pinning it breaks on a change with nothing to do with the behaviour under test. There the assertion is that the counter moved, and it says so in a comment.- Never fragment-match a deterministic output — assert the whole value with
.toBe(fullValue), inlined in theexpectcall rather than an intermediateconst expected*;toMatchInlineSnapshot()(empty, filled withpnpm test -u) when it is bulky or multiline. A full snapshot subsumes paired negative assertions, so drop the.not.toContain(...)..toContain/.toMatchsurvive only for genuine membership on non-deterministic content (a runtime UUID or temp path); output embedding a machine-specific path isn't snapshot-safe — fragment-match or assert behaviour portably. - Once + args →
toHaveBeenCalledExactlyOnceWith(...), also with no args.toHaveBeenCalledOnceWithis BANNED — jest-extended, absent from Vitest 4, fails typecheck. Where it doesn't fit:toHaveBeenCalledTimes(1)+toHaveBeenCalledWith(...). takeOne(arr, index)forarr[index]undernoUncheckedIndexedAccess— not universal, preferfindwhen more idiomatic.assert.exists(value)narrows nullables and fails fast instead of?? []. Cloning: see thetypescriptskill.- No unnecessary destructure — for plain objects, read a property directly when used once. Stores and composables keep the
piniaskill's destructure ordering, unchanged in tests. - CRITICAL —
toThrowErrorMatchingInlineSnapshot(...)is the ONLY accepted error assertion, async (.rejects.) and sync (expect(() => fn())) alike: it captures the exact message. BANNED:toThrow(),toThrow(arg),.rejects.toThrow(...),toThrowError(...),toBeInstanceOf(...), hand-rolledtry { fn(); expect.fail() } catch. Filling the snapshot in — reconstructing the message rather than pasting it, the opaque-third-party exception, and why atest.eachrow cannot carry one — isreferences/error-assertions.md.
Mocking
- Mock the smallest seam that makes the behaviour reachable, never re-declare a mock another file owns, prefer driving real state to faking it —
references/module-mocks.md, which also owns which cleanup hook a mock needs (it follows how the mock was created, and the wrong one leaks call history into the next test) and the rules forvi.stubGlobal/vi.stubEnv. vi.fn()always takes its signature —vi.fn<(input: CreateEmojiInput) => Promise<void>>(). A barevi.fn()infersunknownparameters, so destructuring a recorded call (mock.calls.map(([{ id }]) => id)) is an implicit-anylint error andmockResolvedValueaccepts anything. Write the real signature, importing the production input/return types rather than restating their fields.
Reactive Effects and Timers
- No
nextTick— no DOM, sync effects fire immediately; useflushPromises()from@vue/test-utilsfor async watch callbacks. - Fake timers, and any promise the test resolves by hand, follow
references/timers-and-hand-resolved-promises.md— onevi.useFakeTimers({ now: 0 })inbeforeEachwith an unconditional restore inafterEach,toFakenarrowed rather than widened, andPromise.withResolversinstead of aletclosed over by an executor. - Polling is banned — CRITICAL, repo-wide (
expect.poll,vi.waitFor,vi.waitUntil, retry-until loops; all but the loops lint-enforced viano-restricted-properties). Await the real completion signal: promises,flushPromises(), emitted events, orwaitForSynchronizedFunctions()for fire-and-forget work throughgetSynchronizedFunction. Standard:apps/web/content/docs/architecture/no-polling.md. To prove a caller awaits its own side effect, gate a double and drain one boundary — under fake timers that boundary isawait vi.advanceTimersByTimeAsync(0), never a baresetTimeoutpromise nor the syncvi.advanceTimersByTime(references/module-mocks.md).
Running Tests
- Always use
run_in_background: trueforpnpm lint,pnpm typecheck, and test commands. - Never run the full suite locally —
pnpm test <paths> -u --runwith the paths the change touched. CI is the regression net and shards it across runners; a local run answers one question about one change. The scoping rule is thepackage-scriptsskill's; full-run-only failures and the Windows module allowlist arereferences/running-the-suite.md. -t "name"is not a scope — pass paths as well. A name filter picks which tests execute; every test file in range is still collected, transformed and imported first, so-talone spends a full suite's startup to run a handful of assertions. Whenever a run is narrowed by name — refreshing the bundle-size snapshots is the standing case,-t "size" index.test.ts(references/platform-and-bundle-tests.md) — narrow it by path in the same command.-ucan rewrite a snapshot belonging to a test it never ran.packages/vue-phaserjs/src/index.test.tssplits its size snapshots by platform withtest.skipIf(process.platform === "win32"), and a broad-uon Windows wrote the Windows byte counts into the POSIX slots — the two then read identically, which is the one thing that file exists to prevent, and it fails on CI's ubuntu runner rather than locally. So-ugets the narrowest path list that can produce the diff, andgit diffon the updated snapshots is read before committing: a snapshot that moved in a file the change never touched is the tell.
What to Test
Every test earns its line or it is deleted — it earns it only by failing when behaviour a caller or user depends on breaks. Delete on sight, new and existing alike: one asserting a constant's literal value or restating a map/schema (it fails only on a deliberate edit and the diff is the review — unless the literal is fixed outside this repo: a wire/protocol value, security limit or retention window, where catching that edit is the point), one whose subject is now the mock's behaviour rather than ours, one re-covering a branch another test covers. Fewer, wider tests beat many narrow ones: fold a near-duplicate into the test it shadows by widening that fixture. Removing a test a change made redundant is part of the change.
- A test that asserts framework or filesystem wiring earns nothing. A directory exists, a config key holds the value the config file just set, a third-party loader was handed the right glob — none of these fail on a change anyone would ship, and the ones over generated output (a build directory, a typedoc dump) fail on a fresh clone instead. What is worth pinning is a literal a tool cannot import and therefore silently drops: a JSON config's ignore list, or a config evaluated during
postinstall, which runs before any workspace package is built and so cannot resolve one. Not a path the suite beside it would already fail on. Ask what a reviewer would have merged for this test to fire; if the answer is "nothing", it is maintenance with no return. - Never add production API for a test's benefit — before building a completion signal, reset hook or inspection getter onto a primitive, grep for who else would call it; "only the test" means the signal almost certainly exists already. A test-only export means you are testing the wrong seam, or re-inventing a drain the repo owns.
- The recurring subjects where this has already been decided — a shared primitive versus its wrappers, a composable versus the service under it, Zod constraints, a
declare moduleover third-party data, whether a UI change earns a mounted test — arereferences/what-earns-a-test.md.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
testing-esposter- Source
- github.com/esposter/esposter