---
title: "@nuxt/test-utils 4: what changed for Nuxt tests · vuejs.school"
description: "@nuxt/test-utils 4 requires Vitest 4, starts Nuxt in beforeAll so mocks reach plugins and middleware, and breaks composables called in a describe body."
canonical_url: "https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed"
meta:
  "og:description": "@nuxt/test-utils 4 requires Vitest 4, starts Nuxt in beforeAll so mocks reach plugins and middleware, and breaks composables called in a describe body."
  "og:image": "https://vuejs.school/_og/d/a_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHM%2Cdescription_~QG51eHQvdGVzdC11dGlscyA0IHJlcXVpcmVzIFZpdGVzdCA0LCBzdGFydHMgTnV4dCBpbiBiZWZvcmVBbGwgc28gbW9ja3MgcmVhY2ggcGx1Z2lucyBhbmQgbWlkZGxld2FyZSwgYW5kIGJyZWFrcyBjb21wb3NhYmxlcyBjYWxsZWQgaW4gYSBkZXNjcmliZSBib2R5Lg%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA1IG1pbiByZWFk%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LXRlc3QtdXRpbHMtNC13aGF0LWNoYW5nZWQi%2Cs_99bQ2Sp7F2gUUvXk.png"
  "og:image:alt": "@nuxt/test-utils 4: what changed for Nuxt tests · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "@nuxt/test-utils 4: what changed for Nuxt tests · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHM%2Cdescription_~QG51eHQvdGVzdC11dGlscyA0IHJlcXVpcmVzIFZpdGVzdCA0LCBzdGFydHMgTnV4dCBpbiBiZWZvcmVBbGwgc28gbW9ja3MgcmVhY2ggcGx1Z2lucyBhbmQgbWlkZGxld2FyZSwgYW5kIGJyZWFrcyBjb21wb3NhYmxlcyBjYWxsZWQgaW4gYSBkZXNjcmliZSBib2R5Lg%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA1IG1pbiByZWFk%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LXRlc3QtdXRpbHMtNC13aGF0LWNoYW5nZWQi%2Cs_99bQ2Sp7F2gUUvXk.png"
  "twitter:image:alt": "@nuxt/test-utils 4: what changed for Nuxt tests · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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 `beforeAll` hook instead of in a setup file.

The release also brings these non-breaking changes:

- `mockNuxtImport` passes the original implementation to its factory function.
- `registerEndpoint` works 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 `nextTick` import, the `mockNuxtImport` macro and `include` options 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`.

*test/nuxt/navigation.test.ts**Breaks***

```
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:

*test/nuxt/navigation.test.ts**Works***

```
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](https://nuxt.com/docs/4.x/getting-started/testing) 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.

*vitest.config.ts*

```
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:

*app/components/UserCard.vue*

```
<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:

*test/nuxt/UserCard.test.ts*

```
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:

- `mockNuxtImport` replaces an auto-import, here a composable from `app/composables/useSelectedUserId.ts`, with whatever the factory returns. It is a macro that turns into a hoisted `vi.mock`, so call it once per import per file, at the top level.
- `registerEndpoint` answers a request that would have gone to your server, so `useFetch` gets `{ 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.
- `mountSuspended` wraps Vue Test Utils' `mount` and waits for the async `setup`, 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.2
- `happy-dom` 20.0.11 or newer
- `jsdom` 27.4.0 or newer
- `@testing-library/vue` ^8.0.1
- `@jest/globals` 30 or newer
- `@cucumber/cucumber` 11 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:

1. `[nuxt] instance unavailable`: move the composable call out of the `describe` body.
2. A mock that throws on a missing export: spread `importOriginal()` into its factory, or add the export by hand.
3. Anything left is a Vitest 4 change. The [Vitest 4 migration guide](https://v4.vitest.dev/guide/migration#vitest-4), 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.

**Check it stuck**3 questions from the Testing Nuxt apps quiz.

[**Practice now**](https://vuejs.school/learn/testing/testing-nuxt-apps?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 6 min read**Vitest 5: what changed for Vue tests**](https://vuejs.school/community/blog/vitest-5-what-changed-for-vue-tests) [Release notes · 6 min read**Vitest 5 release candidate: what's coming for Vue tests**](https://vuejs.school/community/blog/vitest-5-rc-whats-coming)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-02-17","description":"@nuxt/test-utils 4 requires Vitest 4, starts Nuxt in beforeAll so mocks reach plugins and middleware, and breaks composables called in a describe body.","name":"@nuxt/test-utils 4: what changed for Nuxt tests","url":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed","breadcrumb":{"@id":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-02-17","description":"@nuxt/test-utils 4 requires Vitest 4, starts Nuxt in beforeAll so mocks reach plugins and middleware, and breaks composables called in a describe body.","headline":"@nuxt/test-utils 4: what changed for Nuxt tests","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHM%2Cdescription_~QG51eHQvdGVzdC11dGlscyA0IHJlcXVpcmVzIFZpdGVzdCA0LCBzdGFydHMgTnV4dCBpbiBiZWZvcmVBbGwgc28gbW9ja3MgcmVhY2ggcGx1Z2lucyBhbmQgbWlkZGxld2FyZSwgYW5kIGJyZWFrcyBjb21wb3NhYmxlcyBjYWxsZWQgaW4gYSBkZXNjcmliZSBib2R5Lg%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA1IG1pbiByZWFk%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LXRlc3QtdXRpbHMtNC13aGF0LWNoYW5nZWQi%2Cs_99bQ2Sp7F2gUUvXk.png","@type":["Article","BlogPosting"],"articleSection":["Release notes"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/nuxt-test-utils-4-what-changed#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"@nuxt/test-utils 4: what changed for Nuxt tests","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHM%2Cdescription_~QG51eHQvdGVzdC11dGlscyA0IHJlcXVpcmVzIFZpdGVzdCA0LCBzdGFydHMgTnV4dCBpbiBiZWZvcmVBbGwgc28gbW9ja3MgcmVhY2ggcGx1Z2lucyBhbmQgbWlkZGxld2FyZSwgYW5kIGJyZWFrcyBjb21wb3NhYmxlcyBjYWxsZWQgaW4gYSBkZXNjcmliZSBib2R5Lg%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA1IG1pbiByZWFk%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LXRlc3QtdXRpbHMtNC13aGF0LWNoYW5nZWQi%2Cs_99bQ2Sp7F2gUUvXk.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QG51eHQvdGVzdC11dGlscyA0OiB3aGF0IGNoYW5nZWQgZm9yIE51eHQgdGVzdHM%2Cdescription_~QG51eHQvdGVzdC11dGlscyA0IHJlcXVpcmVzIFZpdGVzdCA0LCBzdGFydHMgTnV4dCBpbiBiZWZvcmVBbGwgc28gbW9ja3MgcmVhY2ggcGx1Z2lucyBhbmQgbWlkZGxld2FyZSwgYW5kIGJyZWFrcyBjb21wb3NhYmxlcyBjYWxsZWQgaW4gYSBkZXNjcmliZSBib2R5Lg%2Ceyebrow_~UmVsZWFzZSBub3RlcyDCtyA1IG1pbiByZWFk%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LXRlc3QtdXRpbHMtNC13aGF0LWNoYW5nZWQi%2Cs_99bQ2Sp7F2gUUvXk.png"}]}
```