Testing strategy

Server and API tests

Testing Nitro routes at three levels — handler logic in isolation, routes through @nuxt/test-utils/e2e, and contracts against an OpenAPI document — with databases in tests (SQLite in memory, Testcontainers, transaction rollback), seeding and isolation, auth in tests, and validation and error-shape assertions.

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.

Know

  • Three levels. Unit: the logic behind a route (pricing, permission rules, mappers) lives in 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.
  • Handlers as functions: an 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.
  • Databases in tests: an in-memory SQLite (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.
  • Isolation and seeding: factories that create the rows a test needs (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.
  • Auth in API tests: create a session the way the app does (call the login route and reuse the 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).
  • Validation and error shapes: assert that invalid bodies produce 400 with the field-level issues in 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.
  • Contract tests: with 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.
  • Cached and stateful routes: clear the Nitro cache storage between tests for defineCachedEventHandler routes; test cache headers and swr behaviour by requesting twice and asserting timing or a marker.
  • Server plugins and hooks: test through effects (a request id header appears on every response; an error is reported to the mock sink) rather than by calling hooks directly.
  • Speed: route-level suites build the app once per run (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.

How it works

Route tests against the built app with an isolated SQLite database and a real session:

test/e2e/api.orders.spec.ts
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:

test/unit/orders.service.spec.ts
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:

test/e2e/api.contract.spec.ts
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:

test/setup/postgres.ts
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()
}
Gotcha· Testing against the dev database

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.

Gotcha· Only testing the happy path

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Write route tests for a POST endpoint covering 400, 401, 404 and the happy path with a login cookie obtained in beforeAll.
  • Extract the endpoint's logic into a service and unit-test it against an in-memory SQLite with real migrations; compare run times.
  • Add a contract test that parses a route's response with the shared schema; then change a field name on the server and watch the client-side test fail as well.

Be able to say

Be able to say· How do you test the server side of a Nuxt application?

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

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.