write-unit-tests
How to write Vitest specs for Brain Games. Use when writing or changing any file under test/nuxt/, when a source file under app/ needs its spec, when the Stop hook reports a missing spec, or when the
SKILL.md.claude/skills/write-unit-tests/SKILL.md
GitHubWrite unit tests
Where specs live
Rule: app/<path>/<Name>.<ext> → test/nuxt/<path>/<Name>.spec.ts.
| Source | Spec |
|---|---|
app/components/games/MemoryPairs.vue |
test/nuxt/components/games/MemoryPairs.spec.ts |
app/components/home/GameTile.vue |
test/nuxt/components/home/GameTile.spec.ts |
app/pages/games/[id].vue |
test/nuxt/pages/games/[id].spec.ts |
app/composables/useProgress.ts |
test/nuxt/composables/useProgress.spec.ts |
app/error.vue |
test/nuxt/error.spec.ts |
No spec needed for: app/app.vue, app/layouts/**, app/components/games/registry.ts, app/data/** (covered by test/nuxt/data/game-contract.spec.ts and copy.spec.ts), app/assets/**, shared/types/**, *.d.ts.
The Stop hook enforces this. Check paths without running the suite:
.claude/hooks/require-tests.sh --dry-run app/path/To/File.vue
Setup
- Vitest runs in the Nuxt environment with happy-dom (
vitest.config.ts). Auto-imports work in specs. - Imports:
~/isapp/,~~/is the repo root. - Shared data lives in
test/nuxt/fixtures.ts:mathGame,reactionGame,breathGame, andmakeGame(bandsAt, lowerIsBetter). Add new fixtures there, not inline copies of game JSON. restoreMocksis on. ResetlocalStorageand fake timers inbeforeEach/afterEach.- Run one file:
npx vitest run test/nuxt/components/games/X.spec.ts. Run all:npm test.
Patterns
Games (app/components/games/*.vue)
Test only the contract: props in, finish(score) out. Model: test/nuxt/components/games/MentalMath.spec.ts.
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('finishes with the number solved when time runs out', async () => {
const wrapper = await mountSuspended(MentalMath, { props: { game: mathGame } })
// play through the DOM: setValue, trigger('click'), trigger('keydown', { key: 'Enter' })
vi.advanceTimersByTime((mathGame.durationSec ?? 60) * 1000 + 200)
expect(wrapper.emitted('finish')).toEqual([[3]])
})
- Drive the game through the DOM like a player. Do not call internal functions.
- Use the real game JSON from
~/data/games/<id>.jsonvia a fixture. - Control randomness with
vi.spyOn(Math, 'random').mockReturnValue(...)when the score depends on it. - Check
finishis emitted exactly once, with the exact score. - Check that no timer is left after
wrapper.unmount():expect(vi.getTimerCount()).toBe(0). - Check keyboard play (Enter or Space) for the main action.
Composables (app/composables/*.ts)
Call the composable inside a component setup with withSetup. Use it from test/nuxt/fixtures.ts if it is there; otherwise copy the helper at the top of test/nuxt/composables/useCountdown.spec.ts.
- State made with
useStatesurvives between tests. CallclearNuxtState('<key>')(from#imports) before each fresh instance. Model:test/nuxt/composables/useProgress.spec.ts. - Pin the date with
vi.useFakeTimers({ toFake: ['Date'] })andvi.setSystemTime(...)for streaks, days and daily picks. - For progress, assert what is written to
localStorageunder thebgardenkey.
Components and pages
Mount with mountSuspended(Component, { props }) or render a route with mountSuspended(Page, { route: '/games/math' }).
- Assert what the user sees: text,
aria-current,aria-label, links (href), disabled state, the count of tiles. - Assert behaviour from functional-requirements: band highlight, "not played yet", struck-through daily games, 404 for an unknown id.
- Seed progress through
localStorage.setItem('bgarden', JSON.stringify({...}))plusclearNuxtState('progress'), not by mockinguseProgress. - Do not snapshot whole components. Do not assert CSS class names unless the class is the behaviour (for example
.youon the current band).
Utils (app/utils/*.ts)
Plain function tests. Cover the edges named in the requirements, for example bandIndex with lowerIsBetter and at: null.
Game JSON
test/nuxt/data/game-contract.spec.ts checks every file against functional-requirements §4. If you add a field to the contract, add the check there.
Write tests from the spec, not the code
- Every expected value comes from
docs/functional-requirements.md, the game JSON, orRUN.<id>indocs/design.html. Never copy an output from the code under test. - Name each test after the behaviour:
it('adds 5 XP when a previous best is beaten'). - One behaviour per test.
Prove each test can fail
A test that never fails is worth nothing. For each new test:
- Make one real mutation in the source that breaks the behaviour: flip a comparison, change a constant, drop a line, skip the
emit. - Run the spec. It must fail, and the failure message must point at that behaviour.
- Undo the mutation. Run again. It must pass.
When the code does not exist yet (red phase), the first failing run is the proof. Note in your report which mutation you used for each test, or "red before implementation".
Pitfalls
- A component that uses
useThemeoruseHeadmust be unmounted (afterEach(() => wrapper.unmount())) beforeclearNuxtState('progress'), or the run reports unhandled rejections. - To mock a game component loaded by
defineAsyncComponentin the registry, thevi.mockfactory must return__esModule: truealong withdefault.
Never
- Never weaken an assertion, add
.skip, or delete a test to get green. Fix the code or ask. - Never mock the unit under test.
- Never use real timers or real waits (
setTimeoutin a test). Use fake timers. - No comments in spec files unless the user asks.