@nuxt/test-utils is the package that lets Vitest run your Nuxt components, composables and pages inside a real Nuxt environment, with auto-imports, plugins and a fake server. Version 4.0.0 was published on 7 February. If your Nuxt tests use it, upgrading means moving to Vitest 4 as well.
The release notes say the breaking changes are "almost all related to requiring at least vitest v4", and that is mostly true. One change goes deeper: Nuxt now starts at a different moment in each test file. That moment is why mocks work better, and also why some existing tests will fail with [nuxt] instance unavailable. The examples below use Nuxt 4.3 with the app/ folder and Vitest 4.0.
What shipped
The changelog for 4.0.0 lists three breaking changes:
- Vitest 4 is required. Vite's native Module Runner replaces vite-node for loading test files, and pool configuration gets simpler.
- Official support for older test runners and DOM environments is dropped, through tighter peer dependency ranges.
- Nuxt starts in a
beforeAllhook instead of in a setup file.
The release also brings these non-breaking changes:
mockNuxtImportpasses the original implementation to its factory function.registerEndpointworks with query parameters in the URL, and endpoints registered in a setup file are no longer lost when modules reset.setupBun, the end-to-end setup for the Bun runtime, accepts setup and teardown timeouts.- Fixes for the Vue wrapper plugin check, a missing
nextTickimport, themockNuxtImportmacro andincludeoptions in the simple setup.
Mocks now apply before Nuxt starts
In version 3, the Nuxt app was created in a Vitest setup file, which runs before your test file is evaluated. Your vi.mock() and mockNuxtImport() calls came later. So a plugin or route middleware could call the real composable, even though your test had mocked it. The notes link three long-standing issues with that symptom.
Version 4 creates the Nuxt app in a beforeAll hook instead. Mocks are registered first, then Nuxt starts and runs its plugins, so they see your mocks.
The second mocking change is small and handy. The factory you pass to mockNuxtImport now receives the real implementation as its argument, which makes a partial mock a one-liner. The notes use this example:
mockNuxtImport('useRoute', original => vi.fn(original))
That gives you a Vitest spy that behaves exactly like the real useRoute until a test changes it with mockImplementation.
Composables in a describe body now fail
Because Nuxt now starts in beforeAll, it does not exist yet while Vitest collects your tests. Collecting means running the body of every describe block to find out which tests it contains. Any Nuxt composable called directly in that body now throws [nuxt] instance unavailable.
import { describe, expect, it } from 'vitest'
describe('navigation', () => {
const router = useRouter()
it('starts on the home page', () => {
expect(router.currentRoute.value.path).toBe('/')
})
})
Move the call into a hook or into the test itself:
import { beforeAll, describe, expect, it } from 'vitest'
describe('navigation', () => {
let router: ReturnType<typeof useRouter>
beforeAll(() => {
router = useRouter()
})
it('starts on the home page', () => {
expect(router.currentRoute.value.path).toBe('/')
})
})
The notes say this applies to useRouter(), useNuxtApp(), useRoute() and every other Nuxt composable or auto-import. If you only need the value in one test, call it at the top of that test.
Mock factories must return the exports you read
The notes list a second change that can break existing tests. When a vi.mock() factory does not return an export and your code reads it, the read now throws an error instead of quietly handing back undefined. The fix is to spread the original module into the factory with importOriginal and override only what you need:
vi.mock('./module', async (importOriginal) => ({
...await importOriginal(),
foo: 'mocked',
}))
importOriginal cannot resolve some virtual modules, such as #build/nuxt.config.mjs. For those, list every export your code reads in the factory.
Configure Vitest and test a component with three helpers
The setup the Nuxt testing docs recommend splits tests into Vitest projects: plain unit tests in Node, and tests that need Nuxt in the nuxt environment. The docs add a third project for end-to-end tests, left out here.
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',
},
},
await defineVitestProject({
test: {
name: 'nuxt',
include: ['test/nuxt/*.{test,spec}.ts'],
environment: 'nuxt',
},
}),
],
},
})
defineVitestProject is what makes the second project a Nuxt one: it loads your Nuxt app and merges its Vite settings, such as the ~ alias and auto-imports, into that project. It returns a promise, hence the await.
Here is a component that reads an ID from one of your own composables and fetches a user:
<script setup lang="ts">
const id = useSelectedUserId()
const { data: user } = await useFetch<{ name: string }>(() => `/api/users/${id.value}`)
</script>
<template>
<h2 v-if="user">{{ user.name }}</h2>
<p v-else>User not found</p>
</template>
The test uses all three helpers:
import { expect, it } from 'vitest'
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import UserCard from '~/components/UserCard.vue'
mockNuxtImport('useSelectedUserId', () => () => ({ value: '42' }))
it('shows the selected user', async () => {
registerEndpoint('/api/users/42', () => ({ name: 'Ada Lovelace' }))
const wrapper = await mountSuspended(UserCard)
expect(wrapper.find('h2').text()).toBe('Ada Lovelace')
})
What each helper does here:
mockNuxtImportreplaces an auto-import, here a composable fromapp/composables/useSelectedUserId.ts, with whatever the factory returns. It is a macro that turns into a hoistedvi.mock, so call it once per import per file, at the top level.registerEndpointanswers a request that would have gone to your server, souseFetchgets{ name: 'Ada Lovelace' }without a running Nitro. It returns a function that removes the endpoint again, for when a later test needs a different answer.mountSuspendedwraps Vue Test Utils'mountand waits for the asyncsetup, which is why the heading is already there when the assertion runs.
How to upgrade
Update the three packages together, because @vitest/coverage-v8 and any other @vitest/* package must match Vitest's major:
npm install -D @nuxt/test-utils@4 vitest@4 @vitest/coverage-v8@4
Then check the tightened peer ranges. If you use these packages, version 4 asks for:
vitest^4.0.2happy-dom20.0.11 or newerjsdom27.4.0 or newer@testing-library/vue^8.0.1@jest/globals30 or newer@cucumber/cucumber11 or newer
Version 3 accepted any happy-dom or jsdom, so an old DOM library in your lockfile is the most likely leftover. npm install -D happy-dom@latest (or jsdom@latest) brings it into range.
Then run the suite and fix what fails, in this order:
[nuxt] instance unavailable: move the composable call out of thedescribebody.- A mock that throws on a missing export: spread
importOriginal()into its factory, or add the export by hand. - Anything left is a Vitest 4 change. The Vitest 4 migration guide, which the notes point to, covers it.
Upgrade now or wait
Upgrade now if you want Vitest 4: version 3 of the test utilities lists Vitest ^3.2.0 as its peer, so the two upgrades happen together anyway. The mocking fix alone is worth it if you have ever written a test for a plugin or route middleware and wondered why the mock did nothing.
Hold off until you can set aside an afternoon if your suite has many describe blocks that set up state with composables at the top level. The fix is mechanical, but it touches every such file, and each of those files fails until it is fixed.