Testing strategy

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.

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.

Know

  • Two styles: Vue Test Utils (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.
  • The Nuxt environment (// @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.
  • Mocking Nuxt pieces: 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.
  • Stores: createTestingPinia({ initialState, stubActions: true }) as a global plugin; actions become spies, so you assert expect(cart.add).toHaveBeenCalledWith(...) instead of the effect.
  • Async and timing: await flushPromises() after interactions that trigger fetches; await nextTick() after state changes; findBy* queries wait; fake timers for debounced inputs.
  • What to assert: rendered text and roles (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.
  • Slots and provide/inject: pass slot content as strings or components; provide context via global.provide or by mounting the parent; compound components are best tested together.
  • Focus and keyboard: attachTo: document.body so document.activeElement works; userEvent.tab()/keyboard('{Escape}') for keyboard behaviour; toHaveFocus() from @testing-library/jest-dom matchers.
  • Browser mode: Vitest can run component tests in a real Chromium via @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.
  • Hydration in tests: component tests mount client-side; hydration mismatches are caught in end-to-end tests, or by rendering with renderToString from vue/server-renderer and hydrating in a browser-mode test.

How it works

A page that fetches data, tested with a fake endpoint and a mocked route:

test/nuxt/ProductPage.spec.ts
// @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:

test/nuxt/CartDrawer.spec.ts
// @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:

test/nuxt/TagInput.spec.ts
// @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:

vitest.config.ts (excerpt)
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' }] },
  },
}
Gotcha· Asserting on wrapper.vm

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.

Gotcha· mockNuxtImport after the import

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.

Verify before the interview:

The current mountSuspended/renderSuspended options (notably route and attachTo), the mockNuxtImport restore API, and the browser-mode entry point in @nuxt/test-utils.

docs ↗

Exercise

Exercise
  • Write the product page test against a page of your own with registerEndpoint; then remove mountSuspended in favour of mount and read the failure about Suspense.
  • Test a modal with renderSuspended and attachTo: document.body: open, tab three times, Escape, and assert focus restoration.
  • Move one measurement-dependent component test to browser mode and compare the run time and what it can assert.

Be able to say

Be able to say· How do you test components in a Nuxt application?

"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."

Your notes

Open in Notes

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