Testing Nuxt apps
Split Nuxt tests into unit, Nuxt and end-to-end projects with @nuxt/test-utils, mount components with mountSuspended(), fake server routes, and replace auto-imports with mockNuxtImport().
What you'll learn
When you finish, you can do these.- 1Split your tests into unit, Nuxt and end-to-end projects
- 2Test a component that loads data, with a fake server route
- 3Replace an auto-import like useRuntimeConfig() in a test
- 4Fix a mount that renders nothing and a mock set twice
Picture a sticker shop, a small online shop built with Nuxt, the framework on top of Vue that adds pages and server code. Its product page reads the page address with useRoute() and its greeting loads the signed-in person's name with useFetch(), without importing either: Nuxt adds those imports, called auto-imports, when it builds the app. Mount one of them with plain Vue Test Utils, the library that puts one component on a page in memory, and nothing stands behind the calls: no Nuxt app, no address to read, no server to answer.
@nuxt/test-utils 4.3 starts a real Nuxt app for your tests and lets you replace the parts you do not want to run. The Code tab holds the shop's files and the tests for its price function, greeting, shop name and product page; by the end you can run them all. You need one idea from Testing Library and browser mode: Vitest projects, named groups of tests in one config, each with its own files and settings.
Three projects in one config
Not every test needs a whole Nuxt app, and booting one costs time, so the first step sorts the shop's tests by what they need. Install the helpers next to Vitest, the test runner; happy-dom is the simulated page the component tests run in.
npm i -D @nuxt/test-utils vitest @vue/test-utils happy-dom
The split goes in vitest.config.ts, the file that configures Vitest, as the three projects the Nuxt docs recommend.
projects: [
{
test: { name: 'unit', include: ['test/unit/*.test.ts'], environment: 'node' },
},
{
test: { name: 'e2e', include: ['test/e2e/*.test.ts'], environment: 'node' },
},
await defineVitestProject({
test: { name: 'nuxt', include: ['test/nuxt/*.test.ts'], environment: 'nuxt' },
}),
],
The unit project runs plain functions in Node, which runs JavaScript outside a browser: no page, no Nuxt. The shop's formatPrice(), which turns 1250 cents into 12.50 euros, is tested there in a few milliseconds.
The nuxt project, wrapped in defineVitestProject(), boots your Nuxt app inside happy-dom first, so auto-imports and ~/, a short name for the app/ folder, work. The e2e project, short for end to end, builds the real app and talks to it over HTTP.
- unitnode, no Nuxt
- nuxtmountSuspended + registerEndpoint
- e2esetup() builds the app
- Playwrightgoto, waitUntil hydration
npx vitest --project nuxt runs one project, and plain npx vitest runs all three.
The shop's product page shows what an e2e test checks. Its file is app/pages/products/[id].vue, where [id] stands for the number in the address; the page reads that number with useRoute() and shows it in a heading, so /products/2 shows Product 2. In the test, setup() from @nuxt/test-utils/e2e builds the app and starts a server once for the file, and $fetch() returns the HTML the server rendered.
describe('product page', async () => {
await setup() // builds the app and starts it once for this file
it('renders the product on the server', async () => {
const html = await $fetch<string>('/products/2')
expect(html).toContain('<h1>Product 2</h1>')
})
})
The test passes when that HTML holds the Product 2 heading. The nuxt project's helpers, such as mountSuspended() below, come from @nuxt/test-utils/runtime instead and need the nuxt environment, so the two sets never share a file.
The diagram's last step, a real browser, is the next lesson, End-to-end tests with Playwright. There goto('/', { waitUntil: 'hydration' }) waits until Vue has taken over the server's HTML.
Components with mountSuspended()
A component that loads data needs two helpers in its test: one to mount it, one to answer its request. Greeting, the file app/components/Greeting.vue in the Code tab, loads the signed-in person's name from /api/me and shows Hello and the name, or Hello, stranger if no name comes back.
<script setup lang="ts">
const { data } = await useFetch<{ name: string }>('/api/me')
</script>
<template>
<p>Hello, {{ data?.name ?? 'stranger' }}</p>
</template>
The await makes the setup asynchronous: it finishes only when the request does. Vue renders such a component only inside <Suspense>, a built-in component that waits for the setup to finish. Nuxt puts one around every page; a test has to bring its own.
mountSuspended() mounts a component inside the test's Nuxt app, wrapped in <Suspense>, and await mountSuspended() waits until the setup has finished.
registerEndpoint() adds a fake server route. A server route is a file that answers the app's own requests: the real server/api/me.get.ts returns Ben, and the test's fake one answers Asha.
registerEndpoint('/api/me', () => ({ name: 'Asha' })) // a fake route for this file
const wrapper = await mountSuspended(Greeting)
expect(wrapper.text()).toBe('Hello, Asha')
The greeting reads Hello, Asha, and the test passes whatever the real route returns.
Replace auto-imports with mockNuxtImport()
Sometimes a test needs an auto-import to return something else, and mockNuxtImport() swaps it for the whole test file.
ShopTitle, the file app/components/ShopTitle.vue, shows the shop's name, Sticker Shop, in its h1 heading. It reads the name with useRuntimeConfig() from the runtime config: the runtimeConfig settings in nuxt.config.ts, the file that configures a Nuxt app. Its test replaces that auto-import to get another name.
// Nuxt reads the runtime config too, so keep the original and change one value
mockNuxtImport('useRuntimeConfig', (original) => () => {
const config = original()
return { ...config, public: { ...config.public, shopName: 'Test Shop' } }
})
Mounted with await mountSuspended(ShopTitle), the heading reads Test Shop.
Before the file runs, mockNuxtImport() becomes vi.mock(), Vitest's call for replacing an import. Vitest hoists that call: it moves it to the top of the file, above the imports, as Testing async code and mocks explains.
The second argument, a factory that builds the replacement, receives the original useRuntimeConfig() as original. The test calls it and changes only shopName, because Nuxt itself reads the runtime config while it boots. A mock that returns only { public: { shopName } } fails the whole file before any test, with [NUXT_E1005] and then TypeError: Cannot read properties of undefined (reading 'afterEach').
What goes wrong
Two mistakes are common in a first Nuxt test: a mount that renders nothing, and a mock set twice. The first mounts Greeting, which awaits useFetch(), with plain mount().
const wrapper = mount(Greeting)
expect(wrapper.text()).toBe('Hello, Asha')
The wrapper is empty, the test fails with AssertionError: expected '' to be 'Hello, Asha', and Vue warns Component <Greeting>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree. Awaiting mountSuspended() brings the <Suspense>, and the test passes.
const wrapper = await mountSuspended(Greeting)
expect(wrapper.text()).toBe('Hello, Asha')
The second mistake comes from testing the product page at two addresses. ProductPage.test.ts has one test for product 2 and one for product 3, and a first try calls mockNuxtImport() on useRoute() once for each:
mockNuxtImport('useRoute', () => () => ({ params: { id: '2' } }))
mockNuxtImport('useRoute', () => () => ({ params: { id: '3' } }))
Both calls are hoisted, the last one wins, and nothing warns: the test that expects product 2 fails with expected 'Product 3' to be 'Product 2'. Mock the import once instead, with a mock function that each test tells what to return. vi.fn() makes that function inside vi.hoisted(), which Vitest hoists too, so it exists before the mock uses it.
const { useRouteMock } = vi.hoisted(() => ({ useRouteMock: vi.fn() }))
mockNuxtImport('useRoute', () => useRouteMock)
Each test then sets what the page sees, such as useRouteMock.mockReturnValue({ params: { id: '2' } }), and both pass.
What to remember
- Split tests into three Vitest projects:
unitin Node for plain code,nuxtthroughdefineVitestProject()for components, ande2efor the built app. Helpers from/runtimeand/e2enever share a file. - Mount Nuxt components with
await mountSuspended(), and giveuseFetch()a known answer withregisterEndpoint(). mockNuxtImport()replaces an auto-import for the whole file. Keep the original when Nuxt uses the function too.- Plain
mount()renders nothing when the setup awaits. Mock an import once per file, and vary avi.hoisted()mock between tests.
In your project
Do these in a real project. Each one says how to confirm it worked.- In a Nuxt 4 project, install
@nuxt/test-utils,vitest,@vue/test-utilsandhappy-dom, add the three projects tovitest.config.ts, and put each test file intest/unit/,test/nuxt/ortest/e2e/. Runnpx vitest run --project nuxt: it reportsTests 4 passed (4). Then runnpx vitest run --project unitand compare the durations: the unit project skips booting Nuxt. - In
Greeting.test.ts, importmountfrom@vue/test-utilsand use it instead ofawait mountSuspended(). The test fails withexpected '' to be 'Hello, Asha'and the<Suspense>warning. Switch back and it passes. - In
ShopTitle.test.ts, replace the factory with() => () => ({ public: { shopName: 'Test Shop' } }). The file fails before its test runs, withCannot read properties of undefined (reading 'afterEach'). Put back the version that spreadsoriginal().