Nuxt Layers

Testing a layer and CI

The playground as a test bench, fixture apps that extend the layer, asserting that overrides win, type-checking, and a CI matrix that catches Nuxt version skew before consumers do.

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.

Know

  • The playground is manual testing. .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.
  • Fixtures are the real tests. 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.
  • Assert the contract, not the implementation. Tests should check that 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.
  • Unit tests for components and composables use @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.
  • Types are part of the contract. Run 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.
  • CI matrix: the lowest Nuxt you declare in 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".
  • Hydration and memory tests belong here too: a Playwright/createPage test that fails on any /Hydration/ console message, and, for layers with server plugins, a memory watermark test; see Hydration and Memory leaks.

How it works

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
test/fixtures/override/nuxt.config.ts
export default defineNuxtConfig({
  extends: ['../../..'],
  runtimeConfig: { public: { teamRelease: 'test' } },
})
test/override.test.ts
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' })
  })
})
test/hydration.test.ts
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([])
  })
})
.github/workflows/ci.yml
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') }}
Gotcha· The fixture that never leaves the repo

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.

Gotcha· Testing the override is testing the priority rule

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Add the 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.
  • Add nuxt typecheck .playground to pnpm test. Introduce a typo in AppConfigInput and confirm CI catches it.
  • Add the matrix workflow; let the nightly job fail and read the error. Note in your NOTES.md what a Nuxt 5 breakage looks like from a layer's point of view.

Be able to say

Be able to say· How do you test a layer?

"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."