A fullstack Nuxt developer tests the server half too, and interviewers ask how: "how do you test an API route", "how do you handle the database in tests", "how do you make sure the frontend and the API agree". The answers are levels, like on the client: pure logic in isolation, routes through the running app, and the contract between the two.
server/utils or shared/ and is tested as plain functions. Route: @nuxt/test-utils/e2e's setup() starts the built app and $fetch('/api/x', { method: 'POST', body }) exercises the real handler, validation, auth and database. Contract: the response shapes are checked against a schema or an OpenAPI document so client and server cannot drift.defineEventHandler result can be called with a constructed H3Event (h3 provides createApp/toWebHandler, and helpers to build events), but the ergonomic path is to keep handlers thin and test the extracted service functions; route-level tests then cover wiring.node:sqlite or better-sqlite3 through Drizzle) when the production database is SQLite or the code is dialect-neutral; Testcontainers to run the real Postgres in Docker for fidelity; transaction rollback per test (begin before, roll back after) for speed with a shared database; a fresh schema via migrations at suite start. Never test against a shared dev database.createUser(), createOrder({ userId })), unique identifiers per test, and cleanup by rollback or truncation; the test database URL comes from runtimeConfig overridden through NUXT_ env vars in setup({ env }) or nuxt.config.test.set-cookie, or setUserSession in a test-only helper endpoint) and pass the cookie with $fetch(..., { headers: { cookie } }); test 401 and 403 paths explicitly, including object-level authorisation (another user's order → 404).data, that unexpected fields are rejected, and that error responses never include stack traces; readValidatedBody gives you the 400 for free, the test proves the schema.nitro.experimental.openAPI Nitro serves an OpenAPI document (/_openapi.json) from your route metadata (defineRouteMeta); validate responses against it in tests, or generate the document from zod schemas and compare. Alternatively the shared zod schema is the contract: the client parses external responses and the server validates inputs with the same object, and a test asserts the route's output parses.defineCachedEventHandler routes; test cache headers and swr behaviour by requesting twice and asserting timing or a marker.setup caches the build across files with the same rootDir); keep the number of route tests proportional to their cost and push logic tests down to the unit level.Route tests against the built app with an isolated SQLite database and a real session:
import { describe, expect, it, beforeAll } from 'vitest'
import { $fetch, fetch, setup } from '@nuxt/test-utils/e2e'
describe('orders API', async () => {
await setup({
server: true,
env: { NUXT_DATABASE_URL: 'file::memory:?cache=shared', NUXT_SESSION_PASSWORD: 'test-password-with-at-least-32-characters' },
})
let cookie = ''
beforeAll(async () => {
await $fetch('/api/test/seed', { method: 'POST' }) // test-only route, enabled by NUXT_TEST_SEED in runtimeConfig
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ email: 'a@example.com', password: 'correct horse battery' }), headers: { 'content-type': 'application/json' } })
cookie = res.headers.get('set-cookie')!.split(';')[0]!
})
it('rejects invalid bodies with field issues', async () => {
const res = await fetch('/api/orders', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ items: [] }) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.data.issues.some((i: { path: string[] }) => i.path.join('.') === 'items')).toBe(true)
expect(JSON.stringify(body)).not.toMatch(/at .*\.ts:\d+/) // no stack traces
})
it('requires a session', async () => {
expect((await fetch('/api/orders', { method: 'POST' })).status).toBe(401)
})
it('creates an order and hides other users\' orders', async () => {
const order = await $fetch<{ id: string }>('/api/orders', { method: 'POST', headers: { cookie }, body: { items: [{ sku: 'kettle', qty: 1 }] } })
expect(order.id).toMatch(/^[0-9a-f-]{36}$/)
const other = await fetch('/api/orders/00000000-0000-0000-0000-000000000000', { headers: { cookie } })
expect(other.status).toBe(404)
})
})
Unit-testing the service behind the route with an in-memory database and Drizzle:
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { beforeEach, describe, expect, it } from 'vitest'
import { createOrder } from '~~/server/services/orders'
import * as schema from '~~/server/db/schema'
describe('createOrder', () => {
let db: ReturnType<typeof drizzle<typeof schema>>
beforeEach(() => {
db = drizzle(new Database(':memory:'), { schema })
migrate(db, { migrationsFolder: 'server/db/migrations' }) // real migrations, fresh schema per test
})
it('computes totals in minor units and rejects unknown SKUs', async () => {
await db.insert(schema.products).values({ sku: 'kettle', priceMinor: 4999 })
const order = await createOrder(db, { userId: 'u1', items: [{ sku: 'kettle', qty: 2 }] })
expect(order.totalMinor).toBe(9998)
await expect(createOrder(db, { userId: 'u1', items: [{ sku: 'nope', qty: 1 }] })).rejects.toMatchObject({ status: 422 })
})
})
Contract: the route output must parse with the shared schema the client uses:
import { describe, expect, it } from 'vitest'
import { $fetch, setup } from '@nuxt/test-utils/e2e'
import { productSchema } from '~~/shared/schemas/product'
describe('contracts', async () => {
await setup({ server: true })
it('GET /api/products/:id matches the shared product schema', async () => {
const raw = await $fetch('/api/products/kettle')
expect(() => productSchema.parse(raw)).not.toThrow() // the client parses with the same schema: no drift possible
})
it('serves an OpenAPI document that lists the route', async () => {
const doc = await $fetch<{ paths: Record<string, unknown> }>('/_openapi.json')
expect(Object.keys(doc.paths)).toContain('/api/products/{id}')
})
})
Postgres fidelity with Testcontainers, when the dialect matters:
import { PostgreSqlContainer } from '@testcontainers/postgresql'
export async function startPostgres() {
const container = await new PostgreSqlContainer('postgres:17').start()
process.env.NUXT_DATABASE_URL = container.getConnectionUri()
return () => container.stop()
}
Tests that mutate the database every developer uses fail randomly and destroy data. An in-memory or containerised database per run, migrations applied fresh, and rollback or truncation between tests is the minimum; the test URL comes from environment overrides, never from a hard-coded config.
Route tests earn their cost on the unhappy paths: 400 with the right field, 401 without a session, 404 for another user's object, 413 for oversized bodies, and no stack traces anywhere. The happy path is usually covered by the end-to-end journey already.
Whether Nitro 2's experimental.openAPI is still the way to expose /_openapi.json (and /_scalar), the defineRouteMeta shape, and @nuxt/test-utils' setup({ env }) option name.
POST endpoint covering 400, 401, 404 and the happy path with a login cookie obtained in beforeAll."At three levels. The logic behind routes lives in service functions in server/utils or shared/, and I unit-test those against an in-memory SQLite with the real migrations, or a Testcontainers Postgres when the dialect matters, so pricing and permission rules get dozens of fast cases. Routes themselves I test through @nuxt/test-utils' setup() and $fetch against the built app, with a session cookie from the real login route, focusing on the unhappy paths: 400 with field issues, 401, 404 for another user's object, no stack traces. And the contract between client and server is a shared zod schema: the server validates inputs with it, the client parses external data with it, and a test asserts each route's output parses, so a renamed field breaks tests on both sides instead of a template in production."
Nothing yet. Notes filed here show up on this page and the section overview.
End-to-end with Playwright
Playwright's model — locators, auto-waiting, web-first assertions, fixtures, tracing — applied to Nuxt through @nuxt/test-utils/e2e and the Playwright fixture, with reusable auth state, network mocking, hydration checks, accessibility and visual assertions, sharding and a flake policy.
Quality gates and CI
Designing the pipeline — stage order, caching, sharding and parallelism, what blocks a merge — plus coverage policy, flake management, accessibility and performance budgets, visual regression, type and mutation tests, preview deployments, and the test-data and environment story.