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.
@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.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.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.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.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.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 }).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.expectTypeOf and assertType in *.test-d.ts files run with vitest --typecheck, so a change in a generic helper's inferred type fails CI.@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.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.A deterministic test of scheduling logic with fake timers and a table:
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:
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:
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:
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'])
})
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.
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.
What Vitest 5.0 changed (released 3 September 2026) and any new defaults relevant to Nuxt projects, plus @nuxt/test-utils' supported Vitest range.
app/utils/leitner.ts and extend it to orderPool in deck.ts with a seeded shuffle.Async variant of the timer advance and observe why.vitest --typecheck to the test script with one test-d.ts file and break a type on purpose."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Testing strategy
How to talk about testing as a senior — confidence per unit of cost, the testing trophy for a Nuxt app, what belongs at each level, mocking at boundaries, flake policy — and how the pages on Vitest, component tests with @nuxt/test-utils, Playwright, API tests and CI quality gates fit together.
Component tests
Vue Test Utils versus Testing Library, the Nuxt Vitest environment with mountSuspended and renderSuspended, mocking auto-imports, components and endpoints, testing async setup and Suspense, emitted events and slots, stores with createTestingPinia, and when Vitest browser mode is worth it.