Component tests are where a Nuxt app gets most of its confidence: they render real components with real child components, interact like a user and assert on what the user would see. The Nuxt flavour adds two things: components use auto-imports and plugins that need the Nuxt runtime, and pages await data in setup, which needs Suspense. @nuxt/test-utils provides both.
mount, wrapper.find, trigger, emitted, setProps, findComponent) is the low-level official tool; Testing Library (render, screen.getByRole, userEvent) enforces user-centric queries so tests break when accessibility breaks and survive refactors. Most teams use Testing Library queries on top of VTU or via @testing-library/vue.// @vitest-environment nuxt or a nuxt project) boots a Nuxt app in happy-dom/jsdom: auto-imports, plugins, useRuntimeConfig, useRoute, useState all work. mountSuspended(Component, options) mounts inside Suspense with the Nuxt plugins, so await useAsyncData in setup resolves; renderSuspended does the same with Testing Library. Options include props, slots, route (a path to mount the component under), global.stubs/plugins, attachTo.mockNuxtImport('useRoute', () => () => ({ params: { id: '1' } })) replaces an auto-import (hoisted; use vi.hoisted to switch per test); mockComponent('MyChart', () => import('./MyChart.stub.vue')) replaces a component; registerEndpoint('/api/products', () => [...]) fakes a Nitro route so useFetch and $fetch return controlled data. Built-in IntersectionObserver and indexedDb mocks are switched on in environmentOptions.nuxt.mock.createTestingPinia({ initialState, stubActions: true }) as a global plugin; actions become spies, so you assert expect(cart.add).toHaveBeenCalledWith(...) instead of the effect.await flushPromises() after interactions that trigger fetches; await nextTick() after state changes; findBy* queries wait; fake timers for debounced inputs.getByRole('button', { name: 'Add' })), attributes that matter for accessibility (aria-expanded, aria-invalid), emitted() payloads, calls to injected functions, navigation (useRouter().push spied via mockNuxtImport). What not to assert: internal refs via wrapper.vm, CSS classes as a proxy for state, exact HTML snapshots.global.provide or by mounting the parent; compound components are best tested together.attachTo: document.body so document.activeElement works; userEvent.tab()/keyboard('{Escape}') for keyboard behaviour; toHaveFocus() from @testing-library/jest-dom matchers.@vitest/browser-playwright (and @nuxt/test-utils/browser), giving real layout, IntersectionObserver and CSS; slower, but right for components that depend on measurement, drag and drop, or visual regression (toMatchScreenshot). Use it for a targeted subset.renderToString from vue/server-renderer and hydrating in a browser-mode test.A page that fetches data, tested with a fake endpoint and a mocked route:
// @vitest-environment nuxt
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import ProductPage from '~/pages/products/[slug].vue'
const { params } = vi.hoisted(() => ({ params: { slug: 'kettle' } }))
mockNuxtImport('useRoute', () => () => ({ params, fullPath: `/products/${params.slug}` }))
registerEndpoint('/api/products/kettle', () => ({ id: '1', name: 'Kettle', price: { amount: 4999, currency: 'EUR' }, status: 'published' }))
describe('product page', () => {
it('renders the product from the API', async () => {
const wrapper = await mountSuspended(ProductPage, { route: '/products/kettle' })
expect(wrapper.text()).toContain('Kettle')
expect(wrapper.find('h1').text()).toBe('Kettle')
})
it('shows a 404 error for a missing product', async () => {
registerEndpoint('/api/products/missing', () => { throw createError({ status: 404, statusText: 'Not found' }) })
params.slug = 'missing'
await expect(mountSuspended(ProductPage, { route: '/products/missing' })).rejects.toMatchObject({ status: 404 })
})
})
A component tested through its accessible interface with Testing Library, a stubbed store and keyboard interaction:
// @vitest-environment nuxt
import { renderSuspended } from '@nuxt/test-utils/runtime'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import CartDrawer from '~/components/Cart/Drawer.vue'
import { useCartStore } from '~/stores/cart'
describe('CartDrawer', () => {
it('opens, traps focus and removes an item through the store', async () => {
const user = userEvent.setup()
const pinia = createTestingPinia({ initialState: { cart: { items: [{ id: 'a', name: 'Kettle', price: 49.99, qty: 1 }] } } })
await renderSuspended(CartDrawer, { global: { plugins: [pinia] }, attachTo: document.body })
await user.click(screen.getByRole('button', { name: /open cart/i }))
const dialog = screen.getByRole('dialog', { name: /your cart/i })
expect(dialog).toBeVisible()
expect(dialog).toContainElement(document.activeElement as HTMLElement) // focus moved inside
await user.click(screen.getByRole('button', { name: /remove kettle/i }))
expect(useCartStore().remove).toHaveBeenCalledWith('a') // stubbed action: assert the call, not the effect
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: /open cart/i })).toHaveFocus() // focus restored
})
})
Emitted events, v-model and slots with plain Vue Test Utils:
// @vitest-environment nuxt
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { expect, it } from 'vitest'
import TagInput from '~/components/Form/TagInput.vue'
it('adds a tag on Enter and emits update:modelValue + add', async () => {
const wrapper = await mountSuspended(TagInput, { props: { label: 'Tags', modelValue: ['vue'] } })
const input = wrapper.get('input')
await input.setValue('nuxt')
await input.trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['vue', 'nuxt']])
expect(wrapper.emitted('add')?.[0]).toEqual(['nuxt'])
expect(wrapper.get('input').element.value).toBe('') // draft cleared
})
A browser-mode project for the few components that need real layout:
import { playwright } from '@vitest/browser-playwright'
// inside test.projects:
{
test: {
name: 'browser',
include: ['test/browser/**/*.spec.ts'],
browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }] },
},
}
expect(wrapper.vm.open).toBe(true) tests the implementation: renaming the ref breaks the test while the behaviour is unchanged, and it passes when the dialog never rendered. Assert what the user gets: the dialog is in the document with the right name and focus.
mockNuxtImport is hoisted like vi.mock; variables it references must come from vi.hoisted, and calling it inside a test does not affect components already imported. Put mocks at the top of the file and switch behaviour through hoisted state.
The current mountSuspended/renderSuspended options (notably route and attachTo), the mockNuxtImport restore API, and the browser-mode entry point in @nuxt/test-utils.
registerEndpoint; then remove mountSuspended in favour of mount and read the failure about Suspense.renderSuspended and attachTo: document.body: open, tab three times, Escape, and assert focus restoration."In the Nuxt Vitest environment, because components rely on auto-imports and plugins, and with mountSuspended or renderSuspended, because pages await data in setup inside Suspense. I fake the boundaries: registerEndpoint for Nitro routes so useFetch returns controlled data, mockNuxtImport for things like useRoute, createTestingPinia so store actions become spies, fake timers for debounce. Then I interact the way a user does, through roles, labels and the keyboard with Testing Library's queries, and assert on what renders, on emitted payloads and on focus, never on wrapper.vm. Components that depend on real layout or measurement get a small browser-mode project, and hydration is checked end-to-end."
Nothing yet. Notes filed here show up on this page and the section overview.
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.
End-to-end with Playwright
Playwright's model — locators, auto-waiting, web-first assertions, fixtures, tracing — applied to Nuxt through @nuxt/test-utils/e2e and the Playwright fixture, with reusable auth state, network mocking, hydration checks, accessibility and visual assertions, sharding and a flake policy.