---
title: "Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end · vuejs.school"
description: "Set up @nuxt/test-utils 4 with Vitest 4 projects, test a useFetch component with a fake endpoint, mock useRoute in a page test, and add end-to-end tests."
canonical_url: "https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint"
meta:
  "og:description": "Set up @nuxt/test-utils 4 with Vitest 4 projects, test a useFetch component with a fake endpoint, mock useRoute in a page test, and add end-to-end tests."
  "og:image": "https://vuejs.school/_og/d/a_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5kIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5k%2Cdescription_~U2V0IHVwIEBudXh0L3Rlc3QtdXRpbHMgNCB3aXRoIFZpdGVzdCA0IHByb2plY3RzLCB0ZXN0IGEgdXNlRmV0Y2ggY29tcG9uZW50IHdpdGggYSBmYWtlIGVuZHBvaW50LCBtb2NrIHVzZVJvdXRlIGluIGEgcGFnZSB0ZXN0LCBhbmQgYWRkIGVuZC10by1lbmQgdGVzdHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLW51eHQtYXBwcy1tb3VudHN1c3BlbmRlZC1hbmQtcmVnaXN0ZXJlbmRwb2ludCI%2Cs_rC22Z2MUsvVIPEzS.png"
  "og:image:alt": "Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5kIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5k%2Cdescription_~U2V0IHVwIEBudXh0L3Rlc3QtdXRpbHMgNCB3aXRoIFZpdGVzdCA0IHByb2plY3RzLCB0ZXN0IGEgdXNlRmV0Y2ggY29tcG9uZW50IHdpdGggYSBmYWtlIGVuZHBvaW50LCBtb2NrIHVzZVJvdXRlIGluIGEgcGFnZSB0ZXN0LCBhbmQgYWRkIGVuZC10by1lbmQgdGVzdHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLW51eHQtYXBwcy1tb3VudHN1c3BlbmRlZC1hbmQtcmVnaXN0ZXJlbmRwb2ludCI%2Cs_rC22Z2MUsvVIPEzS.png"
  "twitter:image:alt": "Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end · vuejs.school"
---

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

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

*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',
        },
      },
      {
        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`:

*app/components/TodoList.vue*

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

*test/nuxt/TodoList.test.ts*

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

*app/pages/todos/\[id].vue*

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

*test/nuxt/todo-page.test.ts*

```
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 `beforeAll` hook instead of a setup file, so mocks reliably apply before plugins and middleware run. The price: a composable called directly inside a `describe` callback runs too early and throws `[nuxt] instance unavailable`. Move such calls into `beforeAll`, `beforeEach` or the test itself.
- **Partial mocks throw.** A `vi.mock()` factory that leaves out an export now throws when that export is used, instead of returning `undefined`. Spread `await 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".

*test/e2e/todos.test.ts*

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

- `unit` runs in plain Node with no DOM and no Nuxt. Put pure functions here, such as the helpers in `shared/utils`. These tests take milliseconds.
- `nuxt` runs 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 in `useState` or a store carries over between tests unless you reset it.
- `e2e` runs a production build and a server for every `describe` block that calls `setup()`. The default `setupTimeout` is two minutes, four on Windows, which tells you what a build can cost. While you work, pass `host: 'http://localhost:3000'` to `setup()` 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.

**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/testing-nuxt-apps-mountsuspended-and-registerendpoint#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-05","description":"Set up @nuxt/test-utils 4 with Vitest 4 projects, test a useFetch component with a fake endpoint, mock useRoute in a page test, and add end-to-end tests.","name":"Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end","url":"https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint","breadcrumb":{"@id":"https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint"]}],"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/testing-nuxt-apps-mountsuspended-and-registerendpoint#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-05","description":"Set up @nuxt/test-utils 4 with Vitest 4 projects, test a useFetch component with a fake endpoint, mock useRoute in a page test, and add end-to-end tests.","headline":"Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5kIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5k%2Cdescription_~U2V0IHVwIEBudXh0L3Rlc3QtdXRpbHMgNCB3aXRoIFZpdGVzdCA0IHByb2plY3RzLCB0ZXN0IGEgdXNlRmV0Y2ggY29tcG9uZW50IHdpdGggYSBmYWtlIGVuZHBvaW50LCBtb2NrIHVzZVJvdXRlIGluIGEgcGFnZSB0ZXN0LCBhbmQgYWRkIGVuZC10by1lbmQgdGVzdHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLW51eHQtYXBwcy1tb3VudHN1c3BlbmRlZC1hbmQtcmVnaXN0ZXJlbmRwb2ludCI%2Cs_rC22Z2MUsvVIPEzS.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/testing-nuxt-apps-mountsuspended-and-registerendpoint#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Testing Nuxt apps: mountSuspended, registerEndpoint and end-to-end","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_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5kIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5k%2Cdescription_~U2V0IHVwIEBudXh0L3Rlc3QtdXRpbHMgNCB3aXRoIFZpdGVzdCA0IHByb2plY3RzLCB0ZXN0IGEgdXNlRmV0Y2ggY29tcG9uZW50IHdpdGggYSBmYWtlIGVuZHBvaW50LCBtb2NrIHVzZVJvdXRlIGluIGEgcGFnZSB0ZXN0LCBhbmQgYWRkIGVuZC10by1lbmQgdGVzdHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLW51eHQtYXBwcy1tb3VudHN1c3BlbmRlZC1hbmQtcmVnaXN0ZXJlbmRwb2ludCI%2Cs_rC22Z2MUsvVIPEzS.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5kIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVzdGluZyBOdXh0IGFwcHM6IG1vdW50U3VzcGVuZGVkLCByZWdpc3RlckVuZHBvaW50IGFuZCBlbmQtdG8tZW5k%2Cdescription_~U2V0IHVwIEBudXh0L3Rlc3QtdXRpbHMgNCB3aXRoIFZpdGVzdCA0IHByb2plY3RzLCB0ZXN0IGEgdXNlRmV0Y2ggY29tcG9uZW50IHdpdGggYSBmYWtlIGVuZHBvaW50LCBtb2NrIHVzZVJvdXRlIGluIGEgcGFnZSB0ZXN0LCBhbmQgYWRkIGVuZC10by1lbmQgdGVzdHMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLW51eHQtYXBwcy1tb3VudHN1c3BlbmRlZC1hbmQtcmVnaXN0ZXJlbmRwb2ludCI%2Cs_rC22Z2MUsvVIPEzS.png"}]}
```