Skip to content

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.

CommandWhat it runs
pnpm test --rununit and nuxt
pnpm test:unit --rununit only, in seconds
pnpm test:nuxt --runnuxt only
pnpm test:coverageBoth, with v8 coverage
pnpm test:e2eEvery Playwright spec, about three minutes
pnpm test:e2e:smokeThe specs tagged @smoke: sign-in and payments paths
pnpm test:e2e:uiPlaywright's UI mode

pnpm test without --run starts Vitest in watch mode.

How it works

  • unit runs test/unit/*.test.ts and your suites in test/unit/custom/, in Node. vitest.config.ts(Licensees only) gives it the #server, #shared, ~ and ~~ aliases, and points every build-generated #*-registry module at a stub in test/unit/ with no adapters, so dispatch does nothing unless a test supplies an adapter. Its global setup migrates one PGlite database per run.
  • nuxt runs test/nuxt/*.test.ts and your suites in test/nuxt/custom/ through @nuxt/test-utils in happy-dom. The first mount in a file boots the app, so tests and hooks get 20 seconds.
  • e2e runs test/e2e/*.spec.ts against nuxt dev in 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 fileReads
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/ and test/nuxt/custom/, which the template never writes. At the top of test/unit/ a suite tests your code against the kit's config instead of yours.
  • i18n always 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.ts builds with your wrangler.jsonc.
  • In the nuxt project only what your code imports is pinned. Runtime config, the #*-registry modules and the i18n setup are built from your config, and so are the exclusions under Configuration.
  • withStarterConfig, vi.mock('~~/starter.config', …) and vi.importActual work 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 unit project. Stub each one the code reads with vi.stubGlobal, and import the module under test after the stubs, with await import().
  • stubDatabaseGlobals(orm) installs useDrizzle, tables, createError, setResponseHeader, isUniqueConstraintError and the query operators.
  • Seed without ids and use the ones returning hands 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.client is the PGlite itself, for a raw query(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()
  }))
  • UApp provides the tooltip context. A bare mountSuspended of anything that renders a UTooltip throws from inside Reka UI.
  • A toast's close timer outlives the test and throws once the file's page is torn down. TIMERLESS_TOASTER stops that, and test/unit/uapp-mount-toaster.test.ts(Licensees only) fails on a UApp mount 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 $fetch with mockNuxtImport, as test/nuxt/checkout-button.test.ts(Licensees only) does. vi.stubGlobal('$fetch', …) never reaches a component.
  • Every mockNuxtImport('useRoute', …) returns an object with a query, 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.

HelperUse it to
useTestDatabase() in test-database.tsGet a migrated PGlite per file, emptied before each test. unit only
stubDatabaseGlobals(orm) in database-globals.tsInstall the auto-imports a database-backed module reads
withStarterConfig(patch, fn) in starter-config.tsOverlay sections of starter.config while fn runs, and restore them when it returns or settles
withTenancy(fn, orgs?) in orgs-config.tsRun fn with organizations and invitations on
withPublicConfig(section, patch, fn) in runtime-config.tsPatch runtimeConfig.public the same way. nuxt only
starterConfigFixture(patch) in starter-config-fixture.tsBuild a complete StarterConfig to return from vi.mock
MONTHLY_PLAN, TWO_INTERVAL_PLAN, ONE_TIME_PLANPriced plans to spread into a config patch
fakeH3Event, fakeNitroApp, fakeRoute, fakeResponseType a partial fake of a large external type, with the cast in one place
TIMERLESS_TOASTER, TIMERLESS_TOASTER_COLLAPSEDMount UApp with toasts that never close on their own
rememberCoupon, forgetCoupon, rememberedCouponPlant, clear and read the coupon cookie. Call forgetCoupon in beforeEach. nuxt only
inMemoryThrottles() and fake-durable-object.tsRun the real rate limiter over in-memory storage
installEvents() in events.tsRecord 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:unit and test:nuxt on every pull request and every push to main, and a build job beside it. A job that already passed on the exact same git tree skips its steps. Its changed-config job runs only in the kit's own repository, so yours shows it skipped: it checks that test/shipped/starter.config.ts still matches the kit's config, then reruns typecheck and both projects under test/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) runs test:e2e:smoke when you add the e2e label 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

SettingWhereDefaultWhat it does
hookTimeoutvitest.config.ts20_000 in both projectsTime for PGlite to open and Nuxt to boot in beforeAll
testTimeoutvitest.config.ts20_000 in nuxtTime per component test, including the first boot
silentvitest.config.ts'passed-only'Prints console output only for failing tests
PASSKEY_TESTS, MULTI_LOCALE_TESTS, EMAIL_TEMPLATE_TESTSvitest.config.tsexcluded when their feature is offSuites skipped while auth.passkey is off, with one language, or with email off
workersplaywright.config.ts1Every worker would start its own nuxt dev
timeoutplaywright.config.ts30 s, 90 s on CITime per spec
retries, maxFailuresplaywright.config.ts0 locally, 2 on CIRetries per spec, and failed specs before the run stops (0 is no limit)
serverStartTimeoutplaywright.config.ts60 s, 240 s on CITime 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 the tx you were given.
  • PGlite can't race two transactions. To test a race, spy on database.orm.transaction and run the competing write just before the real one opens, as admin-ban.test.ts does.
  • unovis charts never settle under happy-dom. Replace DashboardStackedBarChart with mockComponent in any test that mounts a card holding one.
  • happy-dom's :has() ignores descendant combinators, so body:has(A B) matches whenever an A exists. Query A B directly instead.
  • A type-level test is a // @ts-expect-error above a call in a closure that never runs, with expect.assertions(0). pnpm typecheck covers test/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.