Mount a Nuxt component with Vue Test Utils' mount() and it fails as soon as it touches useFetch, useRoute or anything else Nuxt provides, because there is no Nuxt app around it. @nuxt/test-utils supplies one, so you can test a component that fetches data, or a page that reads the route, without starting your server. The same package also builds and runs the real app for end-to-end tests in a browser.
The examples use @nuxt/test-utils 4.0.0, published on 7 February, Vitest 4.0 and Nuxt 4.3, with the Nuxt 4 layout: components and pages under app/, server routes under server/. The setup follows the Nuxt testing guide, and the version 4 changes come from the v4.0.0 release notes.
Install and configure three Vitest projects
Install the test utilities, Vitest, Vue Test Utils, a DOM implementation and the Playwright core for browser tests:
npm i -D @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core
The guide recommends Vitest projects, separate groups of tests with their own environment, so each test runs only as much machinery as it needs:
import { defineConfig } from 'vitest/config'
import { defineVitestProject } from '@nuxt/test-utils/config'
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['test/unit/*.{test,spec}.ts'],
environment: 'node',
},
},
{
test: {
name: 'e2e',
include: ['test/e2e/*.{test,spec}.ts'],
environment: 'node',
},
},
await defineVitestProject({
test: {
name: 'nuxt',
include: ['test/nuxt/*.{test,spec}.ts'],
environment: 'nuxt',
},
}),
],
},
})
Each project picks its tests by folder. Files in test/unit/ and test/e2e/ run in plain Node. Files in test/nuxt/ run in the nuxt environment, which defineVitestProject() sets up from your app. Nuxt also adds that folder to the app's TypeScript context, so auto-imports and the ~ alias type-check there without extra setup.
The config uses top-level await, so your package.json needs "type": "module", which new Nuxt projects already have, or the file must be called vitest.config.mts. If every test you write needs Nuxt and you have no end-to-end tests, the shorter defineVitestConfig({ test: { environment: 'nuxt' } }) from the same import works too.
Test a component that calls useFetch
This list loads todos from /api/todos:
<script setup lang="ts">
interface Todo {
id: number
title: string
done: boolean
}
const { data: todos, error } = await useFetch<Todo[]>('/api/todos')
</script>
<template>
<p v-if="error">Could not load todos.</p>
<ul v-else>
<li v-for="todo in todos" :key="todo.id">
<NuxtLink :to="`/todos/${todo.id}`">{{ todo.title }}</NuxtLink>
</li>
</ul>
</template>
The test lives in test/nuxt/, so it runs in the Nuxt environment:
import { describe, expect, it } from 'vitest'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import TodoList from '~/components/TodoList.vue'
registerEndpoint('/api/todos', () => [
{ id: 1, title: 'Buy milk', done: true },
{ id: 2, title: 'Write tests', done: false },
])
describe('TodoList', () => {
it('renders the todos from the API', async () => {
const wrapper = await mountSuspended(TodoList)
expect(wrapper.findAll('li')).toHaveLength(2)
expect(wrapper.text()).toContain('Write tests')
})
})
mountSuspended() wraps Vue Test Utils' mount(). It renders the component inside the test's Nuxt app and waits for its async setup, here the await useFetch, before it returns. Everything after it can assert on the finished HTML, and the wrapper has the usual find, text and trigger methods.
registerEndpoint() is the fake API. Your Nitro server does not run in this environment. Requests to relative URLs go to a small in-memory server instead, and an unregistered path answers with a 404, which would put the component in its error state.
The handler can return any JSON. For a method other than GET, pass an object: registerEndpoint('/api/todos', { method: 'POST', handler: () => ({ id: 3 }) }). Version 4 fixed two bugs here: endpoints registered with a query string in the URL, such as /api/todos?done=true, now match, and endpoints registered in a setup file are no longer lost when Vitest resets modules.
Mock useRoute in a page test
A detail page reads its id from the route:
<script setup lang="ts">
interface Todo {
id: number
title: string
done: boolean
}
const route = useRoute()
const { data: todo } = await useFetch<Todo>(`/api/todos/${route.params.id}`)
</script>
<template>
<h1 v-if="todo">{{ todo.title }}</h1>
<p v-else>Todo not found</p>
</template>
Pages are components, so mountSuspended() mounts them directly. To control what useRoute() returns, mock the auto-import with mockNuxtImport():
import { afterEach, expect, it, vi } from 'vitest'
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import TodoPage from '~/pages/todos/[id].vue'
mockNuxtImport(useRoute, original => vi.fn(original))
registerEndpoint('/api/todos/2', () => ({ id: 2, title: 'Write tests', done: false }))
afterEach(() => {
vi.mocked(useRoute).mockReset()
})
it('loads the todo named in the route', async () => {
vi.mocked(useRoute).mockReturnValue({ ...useRoute(), params: { id: '2' } })
const wrapper = await mountSuspended(TodoPage)
expect(wrapper.find('h1').text()).toBe('Write tests')
})
mockNuxtImport() is a macro: it is rewritten into a vi.mock() call and hoisted to the top of the file, so it may appear only once per import in a file. Its first argument is the import itself, as here, or its name as a string. New in version 4, the factory receives the original function. Wrapping it in vi.fn(original) gives you a mock that behaves like the real useRoute until a test says otherwise.
Inside the test, mockReturnValue() hands the page a copy of the real route with params.id set to '2', the id the fake endpoint answers for. mockReset() in afterEach then puts the original implementation back, which is what mockReset() has done in Vitest since version 3.
Two version 4 changes affect tests like these, and any tests you bring over from version 3:
- Nuxt starts later. The app now starts in a
beforeAllhook instead of a setup file, so mocks reliably apply before plugins and middleware run. The price: a composable called directly inside adescribecallback runs too early and throws[nuxt] instance unavailable. Move such calls intobeforeAll,beforeEachor the test itself. - Partial mocks throw. A
vi.mock()factory that leaves out an export now throws when that export is used, instead of returningundefined. Spreadawait importOriginal()into the factory to keep the rest.
Run end-to-end tests against a real build
Component tests never touch your real server. End-to-end tests do: @nuxt/test-utils/e2e builds the app, starts it, and gives you $fetch for server-rendered HTML and createPage for a Playwright browser page.
This test needs two things the component tests did not: a page at app/pages/todos/index.vue that renders <TodoList />, and real routes in server/api/ for the list and for a single todo. Their data must include a todo with id 2 titled "Write tests".
import { describe, expect, it } from 'vitest'
import { $fetch, createPage, setup } from '@nuxt/test-utils/e2e'
describe('todos', async () => {
await setup({ browser: true })
it('server-renders the list', async () => {
const html = await $fetch<string>('/todos')
expect(html).toContain('Write tests')
})
it('opens a todo from the list', async () => {
const page = await createPage('/todos')
await page.getByRole('link', { name: 'Write tests' }).click()
await page.waitForURL('**/todos/2')
expect(await page.getByRole('heading', { level: 1 }).textContent()).toBe('Write tests')
})
})
setup() registers hooks that build and serve the app before the first test and shut it down after the last. $fetch returns the HTML exactly as the server sent it, with no JavaScript run, so the first test checks server rendering on its own. createPage() opens the path in a headless Chromium and waits until Nuxt has hydrated, so the click lands on a live link.
playwright-core does not download browsers, so run npx playwright-core install chromium once. @nuxt/test-utils/runtime and @nuxt/test-utils/e2e cannot share a file, which is one more reason for the separate projects.
What runs where, and what it costs
Each project pays a different start-up price:
unitruns in plain Node with no DOM and no Nuxt. Put pure functions here, such as the helpers inshared/utils. These tests take milliseconds.nuxtruns in happy-dom with a Nuxt app started once per file, plugins included. Each file pays that start-up once; the tests inside are quick. The app is shared by every test in the file, so state inuseStateor a store carries over between tests unless you reset it.e2eruns a production build and a server for everydescribeblock that callssetup(). The defaultsetupTimeoutis two minutes, four on Windows, which tells you what a build can cost. While you work, passhost: 'http://localhost:3000'tosetup()and the tests run against your dev server without building.
Run one project at a time with its name:
npx vitest --project unit
npx vitest --project nuxt
npx vitest run --project e2e
Vitest's summary line splits the total duration into parts, including tests and environment. Because version 4 starts the Nuxt app in a beforeAll hook, its start-up is counted under tests, not environment, so a large tests figure for the nuxt project can be mostly start-up. When that project feels slow, the fix is fewer, fuller files rather than many files with one test each.