Quality
Testing
The kit has three suites. unit runs server and shared code in plain Node,
nuxt mounts components inside a real Nuxt app, and e2e drives a browser
through nuxt dev with Playwright. You work here when you test your own code.
Changing starter.config.ts fails none of the kit's own suites:
Your config and the kit's suites says why.
| Command | What it runs |
|---|---|
pnpm test --run | unit and nuxt |
pnpm test:unit --run | unit only, in seconds |
pnpm test:nuxt --run | nuxt only |
pnpm test:coverage | Both, with v8 coverage |
pnpm test:e2e | Every Playwright spec, about three minutes |
pnpm test:e2e:smoke | The specs tagged @smoke: sign-in and payments paths |
pnpm test:e2e:ui | Playwright's UI mode |
pnpm test without --run starts Vitest in watch mode.
How it works
unitrunstest/unit/*.test.tsand your suites intest/unit/custom/, in Node.vitest.config.ts(Licensees only) gives it the#server,#shared,~and~~aliases, and points every build-generated#*-registrymodule at a stub intest/unit/with no adapters, so dispatch does nothing unless a test supplies an adapter. Its global setup migrates one PGlite database per run.nuxtrunstest/nuxt/*.test.tsand your suites intest/nuxt/custom/through@nuxt/test-utilsin happy-dom. The first mount in a file boots the app, so tests and hooks get 20 seconds.e2erunstest/e2e/*.spec.tsagainstnuxt devin Chromium, one worker at a time.- A passing test prints nothing. Console output shows only for tests that fail
(
silent: 'passed-only').
Your config and the kit's suites
The kit's suites test the configuration the kit ships, whatever your
starter.config.ts says, so turning payments off or taking a permission from a
role fails none of them. In both vitest projects, every import of
starter.config.ts resolves to test/helpers/suite-starter-config.ts, which
answers according to the test file that is running:
| Test file | Reads |
|---|---|
A kit suite at the top of test/unit/ or test/nuxt/ | test/shipped/starter.config.ts, the kit's config byte for byte, with your i18n |
A kit suite named in LIVE_CONFIG_SUITES (test/helpers/live-config-suites.ts) | Your starter.config.ts |
Yours, anywhere under test/unit/custom/ or test/nuxt/custom/ | Your starter.config.ts |
- Put your own suites under
test/unit/custom/andtest/nuxt/custom/, which the template never writes. At the top oftest/unit/a suite tests your code against the kit's config instead of yours. i18nalways comes from your config, because your locale files and the built app follow it.- The kit suites that read your config check it against your own files: that
each locale file has copy for every plan, role and organization level you
declare, and that
nuxt.config.tsbuilds with yourwrangler.jsonc. - In the
nuxtproject only what your code imports is pinned. Runtime config, the#*-registrymodules and the i18n setup are built from your config, and so are the exclusions under Configuration. withStarterConfig,vi.mock('~~/starter.config', …)andvi.importActualwork as before, on whichever config the file reads.
test/unit/schema-cascades.test.ts sweeps every column that references
users, in your tables too. Erasing an account deletes its users row, so a
key that neither cascades nor sets null makes the erasure fail. A column you
set null on purpose, or one that keeps no key so a record outlives the account,
goes in test/helpers/custom/schema-cascades.ts, keyed table.column:
export const USER_COLUMNS_WITHOUT_FOREIGN_KEY: Record<string, string> = {
'admin_audit.actor_user_id':
'the staff audit trail outlives the staff account, as activity_log does',
}
export const USER_COLUMNS_SET_NULL: string[] = ['creators.user_id']
Test a server route
Import the route's default export and call it with a fake event. The database
is real Postgres (PGlite), migrated and emptied before every test. This is a
trimmed test/unit/admin-ban.test.ts(Licensees only):
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { z } from 'zod'
import * as schema from '#server/database/schema'
import { stubDatabaseGlobals } from '../helpers/database-globals'
import { fakeH3Event } from '../helpers/fake-h3-event'
import { useTestDatabase } from '../helpers/test-database'
vi.stubGlobal('defineEventHandler', (handler: unknown) => handler)
vi.stubGlobal('z', z)
const { default: banRoute } = await import('#server/api/admin/users/ban.post')
const database = useTestDatabase()
beforeEach(() => {
stubDatabaseGlobals(database.orm)
vi.stubGlobal('requirePermission', () => Promise.resolve({ user: { id: 1 } }))
vi.stubGlobal(
'readValidatedBody',
(event: { body: unknown }, parse: (input: unknown) => unknown) =>
Promise.resolve(parse(event.body)),
)
vi.stubGlobal('recordActivity', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('suspends a customer', async () => {
const [, customer] = await database.orm
.insert(schema.users)
.values([
{ email: 'admin@example.test', role: 'superadmin' },
{ email: 'customer@example.test', role: 'user' },
])
.returning({ id: schema.users.id })
await banRoute(fakeH3Event({ body: { userId: customer!.id, banned: true } }))
const [row] = await database.orm
.select({ bannedAt: schema.users.bannedAt })
.from(schema.users)
.where(eq(schema.users.id, customer!.id))
expect(row?.bannedAt).not.toBeNull()
})
- Nuxt's auto-imports don't exist in the
unitproject. Stub each one the code reads withvi.stubGlobal, and import the module under test after the stubs, withawait import(). stubDatabaseGlobals(orm)installsuseDrizzle,tables,createError,setResponseHeader,isUniqueConstraintErrorand the query operators.- Seed without ids and use the ones
returninghands back. Identities restart at 1 before every test, and an insert that names its id doesn't move the sequence, so the next generated id would collide with it. database.clientis the PGlite itself, for a rawquery(text, params)when SQL reads more plainly than the builder.
Test a component
Mount through UApp when the component can show a toast or a tooltip, and pass
the timerless toaster:
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import UApp from '@nuxt/ui/components/App.vue'
import { flushPromises } from '@vue/test-utils'
import { expect, it } from 'vitest'
import { h } from 'vue'
import ReportCard from '~/components/ReportCard.vue'
import { withStarterConfig } from '../helpers/starter-config'
import { TIMERLESS_TOASTER } from '../helpers/toaster'
registerEndpoint('/api/reports', () => ({ total: 3 }))
it('shows the report count', () =>
withStarterConfig({ payments: { enabled: false } }, async () => {
const app = await mountSuspended(UApp, {
props: { toaster: TIMERLESS_TOASTER },
slots: { default: () => h(ReportCard) },
})
await flushPromises()
expect(app.text()).toContain('3')
app.unmount()
}))
UAppprovides the tooltip context. A baremountSuspendedof anything that renders aUTooltipthrows from inside Reka UI.- A toast's close timer outlives the test and throws once the file's page is
torn down.
TIMERLESS_TOASTERstops that, andtest/unit/uapp-mount-toaster.test.ts(Licensees only) fails on aUAppmount without it. - Unmount what you mount. A leftover page keeps following the shared router, re-renders on every later test, and slows the file until it times out.
- Answer requests with
registerEndpoint. To make one fail, mock$fetchwithmockNuxtImport, astest/nuxt/checkout-button.test.ts(Licensees only) does.vi.stubGlobal('$fetch', …)never reaches a component. - Every
mockNuxtImport('useRoute', …)returns an object with aquery, or the SEO module throws after the test passes.
Helpers and stubs
The shared helpers live in test/helpers/(Licensees only). Tests import
them by relative path.
| Helper | Use it to |
|---|---|
useTestDatabase() in test-database.ts | Get a migrated PGlite per file, emptied before each test. unit only |
stubDatabaseGlobals(orm) in database-globals.ts | Install the auto-imports a database-backed module reads |
withStarterConfig(patch, fn) in starter-config.ts | Overlay sections of starter.config while fn runs, and restore them when it returns or settles |
withTenancy(fn, orgs?) in orgs-config.ts | Run fn with organizations and invitations on |
withPublicConfig(section, patch, fn) in runtime-config.ts | Patch runtimeConfig.public the same way. nuxt only |
starterConfigFixture(patch) in starter-config-fixture.ts | Build a complete StarterConfig to return from vi.mock |
MONTHLY_PLAN, TWO_INTERVAL_PLAN, ONE_TIME_PLAN | Priced plans to spread into a config patch |
fakeH3Event, fakeNitroApp, fakeRoute, fakeResponse | Type a partial fake of a large external type, with the cast in one place |
TIMERLESS_TOASTER, TIMERLESS_TOASTER_COLLAPSED | Mount UApp with toasts that never close on their own |
rememberCoupon, forgetCoupon, rememberedCoupon | Plant, clear and read the coupon cookie. Call forgetCoupon in beforeEach. nuxt only |
inMemoryThrottles() and fake-durable-object.ts | Run the real rate limiter over in-memory storage |
installEvents() in events.ts | Record what the event bus emits, and run deferred listeners with settled() |
A module that reads starter.config when it is imported can't be patched
afterwards. Mock the config before the import instead:
vi.mock('~~/starter.config', async () => {
const { MONTHLY_PLAN, starterConfigFixture } =
await import('../helpers/starter-config-fixture')
return {
default: starterConfigFixture({
payments: {
enabled: true,
modes: ['subscription'],
providers: ['stripe'],
plans: { pro: { ...MONTHLY_PLAN } },
},
}),
}
})
The fixture's roles and grants are a fixed baseline, independent of your
starter.config.ts, so editing your config doesn't change what these tests
exercise.
For the rate limiter, mock cloudflare:workers with the fake and put the
throttles on the event's bindings:
vi.mock('cloudflare:workers', () => import('../helpers/fake-durable-object'))
const event = fakeH3Event({
context: { cloudflare: { env: { AUTH_THROTTLE: inMemoryThrottles() } } },
})
In the unit project, import.meta.dev reads globalThis.__IMPORT_META_DEV__
and import.meta.prerender reads globalThis.__IMPORT_META_PRERENDER__. Both
are false until a test sets them, for example with
vi.stubGlobal('__IMPORT_META_DEV__', true) to run a dev-only branch.
A new build-generated #…-registry module needs a stub in test/unit/ and an
alias in two places: UNIT_ALIASES in vitest.config.ts, and
typescript.nodeTsConfig.compilerOptions.paths in nuxt.config.ts, which
typechecks the same files.
Run the end-to-end suite
pnpm exec playwright install chromium
pnpm test:e2e
playwright.config.ts(Licensees only) starts nuxt dev itself and
supplies what a fresh clone lacks: a session password, an empty
NUXT_SITE_URL, dummy Stripe keys with a price mapped to pro's monthly ref,
and a wrangler config without the AI binding. The dev server migrates the
PGlite it serves before it listens, so the suite runs on a fresh clone. Global
setup clears the local CACHE KV and Nuxt's dev payload cache before each run,
and keeps the database.
A spec imports test and expect from ./fixtures, never from
@nuxt/test-utils/playwright directly, and a unit test enforces it. Specs read
test/e2e/starter.ts(Licensees only) to learn what the build ships
and skip what it doesn't, so the suite passes on any configuration:
import { PASSWORD, seedAccount, uniqueEmail } from './accounts'
import { expectOnDashboard } from './dashboard'
import { test } from './fixtures'
import { shipped } from './starter'
test.skip(!shipped.usesPassword, 'this build has no password registration')
test('a registered account signs in', async ({ page, goto, request }) => {
const email = uniqueEmail()
await seedAccount(request, email)
await goto('/login', { waitUntil: 'hydration' })
await page.getByLabel('Email').fill(email)
await page.getByLabel('Password', { exact: true }).fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expectOnDashboard(page)
})
To add a spec to the set CI runs, pass { tag: '@smoke' } as the second
argument of test().
test/e2e/accounts.ts(Licensees only) holds the shared password,
uniqueEmail(), seedAccount(), registerInPage() and query(sql), which
reads the dev server's database. Fresh addresses use the seed domain, so
pnpm db:seed --reset clears what the suite leaves behind. seedAccount
registers through the standalone request fixture; use registerInPage when
the page itself needs the session cookie.
In CI
ci.yml(Licensees only) runs lint, format check, typecheck,test:unitandtest:nuxton every pull request and every push tomain, and abuildjob beside it. A job that already passed on the exact same git tree skips its steps. Itschanged-configjob runs only in the kit's own repository, so yours shows it skipped: it checks thattest/shipped/starter.config.tsstill matches the kit's config, then reruns typecheck and both projects undertest/changed/starter.config.ts, an app's config with nearly every switch flipped, and with the Serbian locale deleted.commits.yml(Licensees only) runs commitlint over the pull request's title and every commit in it.e2e.yml(Licensees only) runstest:e2e:smokewhen you add thee2elabel to a pull request, on every push while the label stays, or by hand. It needs no secrets and uploads the Playwright report when it fails.
None of these blocks a merge until a ruleset on main requires it. Rulesets
are a repository setting, so your repository needs its own. Require
ci (ubuntu-latest, 22), build and commitlint, with GitHub Actions as the
source. Renaming a job or changing the matrix changes the check name GitHub
waits for.
Configuration
| Setting | Where | Default | What it does |
|---|---|---|---|
hookTimeout | vitest.config.ts | 20_000 in both projects | Time for PGlite to open and Nuxt to boot in beforeAll |
testTimeout | vitest.config.ts | 20_000 in nuxt | Time per component test, including the first boot |
silent | vitest.config.ts | 'passed-only' | Prints console output only for failing tests |
PASSKEY_TESTS, MULTI_LOCALE_TESTS, EMAIL_TEMPLATE_TESTS | vitest.config.ts | excluded when their feature is off | Suites skipped while auth.passkey is off, with one language, or with email off |
workers | playwright.config.ts | 1 | Every worker would start its own nuxt dev |
timeout | playwright.config.ts | 30 s, 90 s on CI | Time per spec |
retries, maxFailures | playwright.config.ts | 0 locally, 2 on CI | Retries per spec, and failed specs before the run stops (0 is no limit) |
serverStartTimeout | playwright.config.ts | 60 s, 240 s on CI | Time for nuxt dev to serve its first page |
Good to know
- A test that hangs inside a transaction is almost always calling
useDrizzle(event)from the transaction callback. PGlite has one connection and the open transaction holds it. Use thetxyou were given. - PGlite can't race two transactions. To test a race, spy on
database.orm.transactionand run the competing write just before the real one opens, asadmin-ban.test.tsdoes. - unovis charts never settle under happy-dom. Replace
DashboardStackedBarChartwithmockComponentin any test that mounts a card holding one. - happy-dom's
:has()ignores descendant combinators, sobody:has(A B)matches whenever anAexists. QueryA Bdirectly instead. - A type-level test is a
// @ts-expect-errorabove a call in a closure that never runs, withexpect.assertions(0).pnpm typecheckcoverstest/unit, so the directive fails the build once the error goes away.
A link with a lock opens the kit’s private repository, which every licensee can read.