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.
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.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..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).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.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./Hydration/ (see Hydration), a memory watermark check over repeated requests, and a bundle budget asserting the size your module adds to a baseline app.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.The Vitest config and a runtime unit test:
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
typecheck: { enabled: true, include: ['test/**/*.test-d.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:
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:
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
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.
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.
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.
nuxt-team-toolkit. Make the E2E test assert the greeting is server-rendered by checking the raw HTML from $fetch, not the DOM..nuxt and snapshots it. Change the template's generator and watch the snapshot fail.{ 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."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."
Dependencies and composition
installModule versus the declarative moduleDependencies field, detecting other modules with hasNuxtModule, choosing dependency versus peerDependency, and cooperating through hooks.
Building, versioning and publishing
What nuxt-module-build emits, the package.json fields that decide whether your module resolves, semver for modules, deprecating an option safely, and getting listed on nuxt.com/modules.