Testing strategy

Unit tests with Vitest

Vitest 5 essentials — projects, matchers, spies and mocks, fake timers, snapshots, type tests, coverage — and how to unit-test the parts of a Nuxt app that need no DOM — utilities, composables, Pinia stores, Leitner scheduling — with MSW for network boundaries.

Unit tests are for logic that does not need a browser: pricing, scheduling, parsing, state transitions. In a Nuxt app that is shared/utils, app/utils, composables that only touch reactivity, and Pinia stores. Vitest runs them in milliseconds with Vite's transform pipeline, so the same TypeScript, aliases and environment variables apply as in the app.

Know

  • Vitest 5 is current (3 September 2026); @nuxt/test-utils 4 supports Vitest 4 and 5. Since Vitest 4 the projects option replaced the workspace file, browser mode is stable with the Playwright and WebdriverIO providers, and V8 coverage uses AST-based remapping for accurate line counts. Config lives in vitest.config.ts (or vite.config.ts test), with include, environment, setupFiles, globals, coverage, typecheck.
  • Anatomy: describe/it/test, beforeEach/afterEach, test.each for tables, test.concurrent, test.skip/.only/.todo/.fails; matchers toBe, toEqual (deep), toStrictEqual (undefined-sensitive), toMatchObject, toContain, toThrow, resolves/rejects, toHaveBeenCalledWith, toBeCloseTo for floats; expect.poll for eventual conditions.
  • Spies and mocks: vi.fn() creates a mock function, vi.spyOn(obj, 'method') wraps an existing one, vi.mock('./module', factory) replaces a module (hoisted; use vi.hoisted for variables it needs), vi.stubGlobal/vi.stubEnv for globals and env, vi.restoreAllMocks in afterEach. Mock at boundaries (network, time, randomness), not the unit under test.
  • Time and randomness: vi.useFakeTimers() + vi.advanceTimersByTime()/vi.runAllTimersAsync() and vi.setSystemTime() make debounce, retry and scheduling deterministic; seed or stub Math.random and crypto.randomUUID when output depends on them.
  • Network: MSW (setupServer in Node) intercepts fetch with request handlers, so code calling $fetch runs against realistic responses without a server; in the Nuxt environment registerEndpoint is the lighter alternative for Nitro routes.
  • Composables and stores: composables that only use reactivity are called directly and asserted after await nextTick(); ones using lifecycle hooks or inject run inside effectScope() or a withSetup helper, or in the Nuxt environment. Pinia stores are unit-tested with setActivePinia(createPinia()), and components against stores with createTestingPinia({ stubActions, initialState }).
  • Snapshots (toMatchSnapshot, toMatchInlineSnapshot) are for stable serialisable output (a generated config, an error message), not for component HTML that changes with every style tweak; review snapshot diffs like code.
  • Type tests: expectTypeOf and assertType in *.test-d.ts files run with vitest --typecheck, so a change in a generic helper's inferred type fails CI.
  • Coverage: @vitest/coverage-v8; include the directories you own, exclude generated and config files, use thresholds as a floor on critical modules rather than a global target.
  • Nuxt gotchas: auto-imports are not available in the node environment, so unit-tested code imports explicitly (or lives in shared/, which should not depend on Nuxt at all); #imports aliases need the nuxt environment; test the Leitner scheduler and deck ordering of this very app as an example of framework-free logic.

How it works

A deterministic test of scheduling logic with fake timers and a table:

test/unit/leitner.spec.ts
import { describe, expect, it, vi } from 'vitest'
import { applyGrade, isDue, newCard } from '~/utils/leitner'

describe('leitner scheduling', () => {
  it.each([
    ['got', 0, 1, 1],
    ['got', 1, 2, 3],
    ['partial', 2, 1, 1],
    ['missed', 3, 0, 0],
  ] as const)('%s from box %i -> box %i due in %i days', (grade, from, toBox, days) => {
    vi.useFakeTimers()
    vi.setSystemTime(new Date('2026-09-21T09:00:00Z'))
    const card = applyGrade({ ...newCard(new Date()), box: from }, grade, new Date())
    expect(card.box).toBe(toBox)
    expect(isDue(card, new Date())).toBe(days === 0)
    vi.advanceTimersByTime(days * 24 * 60 * 60 * 1000)
    expect(isDue(card, new Date())).toBe(true)
    vi.useRealTimers()
  })
})

A composable with debounce and a network call, tested with fake timers and MSW:

test/unit/useSearch.spec.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import { useSearch } from '~/composables/useSearch'

const server = setupServer(
  http.get('/api/search', ({ request }) => HttpResponse.json([`hit for ${new URL(request.url).searchParams.get('q')}`])),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it('debounces and searches once per pause', async () => {
  vi.useFakeTimers()
  const scope = effectScope()
  const query = ref('')
  const { results } = scope.run(() => useSearch(query, { debounce: 250 }))!

  query.value = 'n'
  query.value = 'nu'
  query.value = 'nux'
  await vi.advanceTimersByTimeAsync(250) // one request after the pause
  await nextTick()

  expect(results.value).toEqual(['hit for nux'])
  scope.stop()
  vi.useRealTimers()
})

A Pinia store, unit-tested directly and with stubbed actions from a component's point of view:

test/unit/cart.store.spec.ts
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCartStore } from '~/stores/cart'

describe('cart store', () => {
  beforeEach(() => setActivePinia(createPinia()))

  it('merges quantities for the same item', () => {
    const cart = useCartStore()
    cart.add({ id: 'a', name: 'A', price: 10 })
    cart.add({ id: 'a', name: 'A', price: 10 }, 2)
    expect(cart.items).toEqual([{ id: 'a', name: 'A', price: 10, qty: 3 }])
    expect(cart.total).toBe(30)
  })

  it('resets after checkout', async () => {
    vi.stubGlobal('$fetch', vi.fn().mockResolvedValue({ id: 'order-1' }))
    const cart = useCartStore()
    cart.add({ id: 'a', name: 'A', price: 10 })
    await expect(cart.checkout()).resolves.toEqual({ id: 'order-1' })
    expect(cart.items).toEqual([])
  })
})

Type tests that guard a generic helper:

test/unit/pick.test-d.ts
import { expectTypeOf, it } from 'vitest'
import { pick } from '~/utils/typed'

it('narrows to the picked keys', () => {
  const u = pick({ id: '1', name: 'A', secret: 'x' }, ['id', 'name'])
  expectTypeOf(u).toEqualTypeOf<{ id: string, name: string }>()
  // @ts-expect-error unknown key
  pick({ id: '1' }, ['nope'])
})
Gotcha· vi.mock for the module under test's neighbours

Mocking ~/utils/format so ~/utils/report is "isolated" couples the test to an implementation detail and hides real integration bugs. Mock the network, the clock and randomness; let your own modules run.

Gotcha· Fake timers and promises

vi.advanceTimersByTime does not flush promise microtasks; use advanceTimersByTimeAsync/runAllTimersAsync and await nextTick() after. Forgetting this produces tests that pass only when the timing accidentally works.

Verify before the interview:

What Vitest 5.0 changed (released 3 September 2026) and any new defaults relevant to Nuxt projects, plus @nuxt/test-utils' supported Vitest range.

docs ↗

Exercise

Exercise
  • Write the Leitner table test against this app's app/utils/leitner.ts and extend it to orderPool in deck.ts with a seeded shuffle.
  • Test a debounced composable with fake timers; make it fail by removing the Async variant of the timer advance and observe why.
  • Add vitest --typecheck to the test script with one test-d.ts file and break a type on purpose.

Be able to say

Be able to say· What do you unit test in a Nuxt app, and how?

"Logic that does not need a DOM: pricing and validation rules in shared/, utilities, composables that only use reactivity, and Pinia stores. They run in Vitest's Node project in milliseconds. I make them deterministic by mocking at the boundaries only: fake timers for debounce and scheduling, MSW or registerEndpoint for the network, stubbed randomness, and createPinia for stores; my own modules run for real. Tables with test.each cover the cases, expectTypeOf guards the inferred types of generic helpers, and coverage is a map of what is untested rather than a target. Anything that needs auto-imports or the Nuxt context moves to the nuxt environment project."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.