A layer has no build step and no entry function, so "does it work?" can only be answered by extending it from an app and running that app. That is the whole testing strategy in one sentence: fixtures are tiny Nuxt apps that extend the layer in different ways, and the tests assert what those apps render, expose and type-check. The interview version of this page is short; the CI matrix is what separates a hobby layer from one twenty apps depend on.
.playground/ extends ..; pnpm dev runs it. It proves the happy path inside the layer's own repository, which is exactly where alias bugs hide (see Paths and aliases). Never let it be the only test.test/fixtures/basic/ is a minimal app with extends: ['../../..']; test/fixtures/override/ adds app/components/Team/Button.vue to prove a consumer override wins; test/fixtures/minimal-nuxt/ pins the lowest supported Nuxt. @nuxt/test-utils/e2e builds and boots a fixture with setup({ rootDir }) and gives you $fetch for server-rendered HTML and createPage for browser assertions.TeamButton renders, that /api/_team/health answers, that useAppConfig().team has the defaults, that a consumer override replaces the layer's component, and that nothing the layer ships causes a hydration warning. If the layer changes internals, these keep passing.@nuxt/test-utils/runtime with the Vitest nuxt environment (mountSuspended, mockNuxtImport); point the environment's rootDir at a fixture so auto-imports and app.config resolve.nuxt typecheck on the playground and on a fixture; a layer whose AppConfigInput augmentation or runtimeConfig types break consumers' nuxt typecheck is a breaking change.peerDependencies, latest, and nuxt-nightly (allowed to fail, but visible). Add one job per consuming app fixture if the layer is a base layer: that is the only test that catches "works in the playground, breaks in the app".createPage test that fails on any /Hydration/ console message, and, for layers with server plugins, a memory watermark test; see Hydration and Memory leaks.packages/layers/base/
├─ .playground/ extends: ['..']
├─ test/
│ ├─ fixtures/
│ │ ├─ basic/nuxt.config.ts extends: ['../../..']
│ │ └─ override/ extends: ['../../..'] + app/components/Team/Button.vue
│ ├─ basic.test.ts
│ └─ override.test.ts
└─ vitest.config.ts
export default defineNuxtConfig({
extends: ['../../..'],
runtimeConfig: { public: { teamRelease: 'test' } },
})
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { $fetch, setup } from '@nuxt/test-utils/e2e'
describe('consumer overrides', async () => {
await setup({ rootDir: fileURLToPath(new URL('./fixtures/override', import.meta.url)) })
it('renders the consumer\'s TeamButton, not the layer\'s', async () => {
const html = await $fetch('/')
expect(html).toContain('data-source="consumer"')
expect(html).not.toContain('data-source="layer"')
})
it('exposes the layer health route with the fixture\'s runtime value', async () => {
const res = await $fetch<{ ok: boolean, release: string }>('/api/_team/health')
expect(res).toMatchObject({ ok: true, release: 'test' })
})
})
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { createPage, setup } from '@nuxt/test-utils/e2e'
describe('hydration', async () => {
await setup({ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), browser: true })
it('hydrates without warnings', async () => {
const page = await createPage()
const warnings: string[] = []
page.on('console', msg => { if (/hydration/i.test(msg.text())) warnings.push(msg.text()) })
await page.goto(page.url() ? page.url() : 'http://localhost:3000/', { waitUntil: 'networkidle' })
expect(warnings).toEqual([])
})
})
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
nuxt: ['4.3.0', 'latest', 'npm:nuxt-nightly@5x']
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install
- run: pnpm add -D nuxt@${{ matrix.nuxt }}
- run: pnpm nuxt prepare .playground && pnpm nuxt typecheck .playground
- run: pnpm test
continue-on-error: ${{ contains(matrix.nuxt, 'nightly') }}
test/fixtures/basic still lives inside the layer's folder, so a ~/assets alias in the layer config can resolve by luck. Keep one fixture in a separate workspace package (apps/fixture-consumer) or run the layer's tests from a consuming app's CI as well.
If override.test.ts fails after a refactor, the usual cause is not the component but a changed resolved name: a moved file, a new prefix, pathPrefix toggled. The test is cheap and catches the class of regression consumers hit most.
The current @nuxt/test-utils API names (setup, $fetch, createPage, mountSuspended, mockNuxtImport), the browser: true option, and the nightly install spec (nuxt@npm:nuxt-nightly@5x) before quoting them.
basic and override fixtures and both tests to a layer. Make them pass, then break the override by renaming the consumer's component file and watch which assertion fails.nuxt typecheck .playground to pnpm test. Introduce a typo in AppConfigInput and confirm CI catches it.NOTES.md what a Nuxt 5 breakage looks like from a layer's point of view."A layer only exists once an app extends it, so every test is a fixture app: a basic one that extends the layer and asserts what it ships renders and answers, an override fixture that proves a consumer component wins, and a hydration test that fails on any warning. Units run with mountSuspended in the Vitest Nuxt environment, types with nuxt typecheck on the playground, and CI runs the lowest Nuxt we support, latest and nightly. The one test I insist on is a fixture outside the layer's own folder, because that is the only place alias mistakes show up before a consumer reports them."
Publishing and consuming a layer
The layer starter and its playground, npm packaging rules, workspace versus registry versus git tags, the dev experience of each, and what counts as a breaking change.
Layers vs modules, and layers as architecture
The decision rule, the cases where a team needs both, domain-driven design with one layer per domain, and the places where a layer is the wrong tool.