Module authoring

Testing a module

The module test pyramid with @nuxt/test-utils — runtime unit tests, fixture E2E, build-level assertions, type tests, a nightly CI matrix and the non-functional tests a toolkit needs.

A module's tests are not "tests for a library". A module produces a build, and most of what can go wrong lives in the build output: a template that generated the wrong code, a plugin registered in the wrong order, a declaration that no longer compiles, a chunk that should not be in the client bundle. Component tests will not catch any of that. The useful answer to "how do you test a module?" is a pyramid with five levels and an explanation of what each one can and cannot see.

Know

  • Level 1 — runtime unit tests. defineVitestConfig from @nuxt/test-utils/config with environment: 'nuxt' boots a Nuxt environment inside Vitest. From @nuxt/test-utils/runtime you get mountSuspended (mounts with the Nuxt app context, so composables and async setup work), renderSuspended, mockNuxtImport (replaces an auto-import — it is hoisted, so the factory must be defined with vi.hoisted), mockComponent and registerEndpoint (stubs a server route for $fetch) — those five plus renderSuspended are exactly what the testing guide documents under that subpath, and @nuxt/test-utils/config also exports defineVitestProject for workspace setups. Fast, but it runs your source, not your published build.
  • Level 2 — E2E against a fixture. await setup({ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)) }) from @nuxt/test-utils/e2e builds and serves a real app with your module installed. The same subpath gives you $fetch, fetch, url and createPage. Assert on raw HTML from $fetch('/') for anything that must be server-rendered, and use createPage('/') (Playwright) for behaviour that needs a browser. One fixture per interesting option scenario, not one giant fixture with everything enabled.
  • Level 3 — build-level assertions. The level most modules skip and the one that catches real regressions: read a generated template out of the fixture's .nuxt, snapshot the emitted .d.ts, and assert that a chunk exists or does not exist in the client build (a server-only dependency leaking into the browser bundle is a silent, expensive bug).
  • Level 4 — type tests. vitest --typecheck with expectTypeOf proves the shapes consumers see: options, useRuntimeConfig().public.toolkit, what your plugin provides. Add nuxt typecheck on the playground, which runs vue-tsc against the generated tsconfigs and is the only check that compiles your declarations the way a consuming app does.
  • Level 5 — compatibility matrix. A CI job against the latest Nuxt 4.x and a second against nuxt@npm:nuxt-nightly@5x (the alias the core modules use), allowed to fail. You want to learn about a Nuxt 5 break from your own CI, not from a consumer's upgrade PR.
  • Non-functional tests belong in the same suite because a toolkit's failures are felt in every consuming app: a hydration test that fails on any console message matching /Hydration/ (see Hydration), a memory watermark check over repeated requests, and a bundle budget asserting the size your module adds to a baseline app.
  • Test the published artifact at least once per release. npm pack, install the tarball into a clean nuxi init app, run it. This is the only way to catch a devDependency used from src/runtime, a missing files entry or an export map that resolves in the monorepo and nowhere else.

How it works

The Vitest config and a runtime unit test:

vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    typecheck: { enabled: true, include: ['test/**/*.test-d.ts'] },
  },
})
test/unit/useToolkit.test.ts
import { describe, expect, it, vi } from 'vitest'
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import TkStatus from '../../src/runtime/app/components/TkStatus.vue'

const { useRuntimeConfigMock } = vi.hoisted(() => ({
  useRuntimeConfigMock: vi.fn(() => ({ public: { toolkit: { apiBase: 'https://test.internal' } } })),
}))
mockNuxtImport('useRuntimeConfig', () => useRuntimeConfigMock)

registerEndpoint('/api/_toolkit/health', () => ({ ok: true }))

describe('TkStatus', () => {
  it('renders the health state from the toolkit endpoint', async () => {
    const wrapper = await mountSuspended(TkStatus)
    expect(wrapper.text()).toContain('ok')
  })
})

E2E plus a build-level assertion, against the same fixture:

test/e2e/basic.test.ts
import { fileURLToPath } from 'node:url'
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import { $fetch, createPage, setup } from '@nuxt/test-utils/e2e'

describe('nuxt-team-toolkit', async () => {
  const rootDir = fileURLToPath(new URL('./fixtures/basic', import.meta.url))
  await setup({ rootDir })

  it('server-renders the greeting', async () => {
    const html = await $fetch('/')
    // assert the raw HTML, not the DOM: this proves it was rendered on the server
    expect(html).toContain('<span data-toolkit>hello</span>')
  })

  it('generates the route registry template', async () => {
    const generated = await readFile(`${rootDir}/.nuxt/toolkit/routes.mjs`, 'utf8')
    expect(generated).toMatchSnapshot()
  })

  it('keeps the private token out of the client bundle', async () => {
    const page = await createPage('/')
    expect(await page.content()).not.toContain('secret-token')
  })
})

A type test is three lines in a test/types.test-d.ts picked up by typecheck.include above — expectTypeOf<ModuleOptions['theme']>().toEqualTypeOf<'default' | 'compact'>() fails the suite when an option's shape changes, which no runtime test can see. The matrix that runs all of it:

.github/workflows/ci.yml
strategy:
  fail-fast: false
  matrix:
    nuxt: [latest, nightly]
steps:
  - run: pnpm install
  - if: matrix.nuxt == 'nightly'
    run: pnpm add -D nuxt@npm:nuxt-nightly@5x
  - run: pnpm dev:prepare && pnpm test && pnpm test:types
Gotcha· mockNuxtImport is hoisted

mockNuxtImport compiles to vi.mock, which Vitest hoists above your imports. Referencing a const declared in the file body inside the factory throws "Cannot access before initialization". Declare the mock with vi.hoisted(() => ({ … })) and reference that, as above. The error message points at the mock, not at the hoisting, which is why this costs everyone an afternoon once.

Gotcha· Green unit tests, broken package

Levels 1 and 2 import your source. The published package is dist, built by module-builder with a different module resolution and a different dependency graph. A missing files entry, a bad exports map or a devDependency imported from src/runtime is invisible to every test above and fatal for the first consumer. Add a release-time job that runs npm pack, installs the tarball into a scratch app and boots it.

Gotcha· E2E asserting on the DOM instead of the HTML

createPage gives you the page after hydration, so a component that renders only on the client passes a DOM assertion while producing empty server HTML. If the requirement is "server-rendered", assert on the string from $fetch. Keep both: $fetch for what the crawler sees, createPage for what the user can click.

Exercise

Exercise
  • Write one test at each of levels 1–4 for nuxt-team-toolkit. Make the E2E test assert the greeting is server-rendered by checking the raw HTML from $fetch, not the DOM.
  • Add a second fixture that sets a different option, and a build-level test that reads the generated template from that fixture's .nuxt and snapshots it. Change the template's generator and watch the snapshot fail.
  • Add a GitHub Actions matrix { nuxt: [latest, nightly] } that swaps in nuxt@npm:nuxt-nightly@5x for the nightly job. Let it fail, read the failure, and write down what would have to change for Nuxt 5.
  • Add a bundle-budget assertion: build the fixture, sum the client chunk sizes, and fail if your module adds more than a fixed number of kilobytes over a baseline app.

Be able to say

Be able to say· How do you test a Nuxt module?

"As a pyramid, because most module bugs are build bugs. At the bottom, runtime unit tests with @nuxt/test-utils: environment: 'nuxt', mountSuspended for components, mockNuxtImport for composables, registerEndpoint to stub server routes. Above that, E2E against fixtures with setup({ rootDir }), where I assert on the raw HTML from $fetch for anything that must be server-rendered and use createPage for interaction. Then the level people skip: build-level assertions — read the generated template out of the fixture's .nuxt, snapshot the emitted declarations, and assert that a chunk is or is not in the client bundle. Then type tests with expectTypeOf plus nuxt typecheck on the playground, because that is what proves my declarations compile in a consuming app. In CI I run a matrix against the latest 4.x and against nuxt@npm:nuxt-nightly@5x so I find out about upstream breakage before my consumers do, and I keep the non-functional tests — a hydration-warning test, a memory watermark and a bundle budget — in the same suite, because a toolkit's regressions are multiplied by every app that installs it."