Nuxt / Nitro Testing Patterns
Test Nuxt 3 applications end-to-end: API handlers with real PostgreSQL using transaction rollback isolation, and frontend components with @nuxt/test-utils.
When to Use This Skill
Use this skill when:
- Testing Nuxt 3 / Nitro API handlers
- Testing Vue components, pages, or composables in Nuxt
- Using Kysely or another query builder with PostgreSQL
- Need real database testing (not mocks)
- Want fast, isolated tests without truncation
Reference Files
Backend (API Handlers):
- transaction-rollback.md - Core isolation pattern with Vitest fixtures
- test-utils.md - Mock events, stubs, and assertion helpers
- factories.md - Transaction-bound factory pattern
- vitest-config.md - Vitest configuration for Nitro
- ci-setup.md - GitHub Actions with PostgreSQL service
- async-testing.md - Testing background tasks and automations
Frontend (Components/Pages):
- frontend-testing.md - Component testing with @nuxt/test-utils
Example Files
- test-utils-index.ts - Complete test utilities module
- global-setup.ts - Database reset and migration
- setup.ts - Per-file setup with stubs
- handler.test.ts - Example API handler test
- vitest.config.ts - Vitest configuration
Core Concept: Transaction Rollback
Instead of truncating tables between tests, each test runs inside a database transaction that rolls back at the end:
// Each test gets isolated factories and db access
test("creates user", async ({ factories, db }) => {
const user = await factories.user({ email: "test@example.com" });
// Test your handler
const event = mockPost({}, { name: "New Item" });
const result = await handler(event);
// Verify in database
const saved = await db.selectFrom("item").selectAll().execute();
expect(saved).toHaveLength(1);
});
// Transaction auto-rolls back - no cleanup needed
Benefits:
- Fast: No DELETE/TRUNCATE between tests
- Isolated: Tests can't affect each other
- Real SQL: Catches actual database issues
- Simple: No manual cleanup
Quick Setup
1. Install Dependencies
yarn add -D vitest @vitest/coverage-v8
2. Create Test Utils Structure
server/
test-utils/
index.ts # Factories, fixtures, mock helpers
global-setup.ts # Runs once: reset DB, run migrations
setup.ts # Runs per-file: stub auto-imports
3. Configure Vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "node",
globalSetup: ["./server/test-utils/global-setup.ts"],
setupFiles: ["./server/test-utils/setup.ts"],
},
resolve: {
alias: {
"~": path.resolve(__dirname),
},
},
});
4. Write Tests
// server/api/users/index.post.test.ts
import { describe, test, expect, mockPost, expectHttpError } from "~/server/test-utils";
import handler from "./index.post";
describe("POST /api/users", () => {
test("creates user with valid data", async ({ factories: _, db }) => {
const event = mockPost({}, {
email: "new@example.com",
name: "New User"
});
const result = await handler(event);
expect(result.id).toBeDefined();
expect(result.email).toBe("new@example.com");
// Verify persisted
const saved = await db
.selectFrom("user")
.where("id", "=", result.id)
.selectAll()
.executeTakeFirst();
expect(saved?.name).toBe("New User");
});
test("throws 400 for missing email", async ({ factories: _ }) => {
const event = mockPost({}, { name: "No Email" });
await expectHttpError(handler(event), { statusCode: 400 });
});
});
Key Patterns
Mock Event Helpers
// GET with route params and query
const event = mockGet({ id: 123 }, { include: "details" });
// POST with body
const event = mockPost({}, { name: "Test", status: "active" });
// PATCH with route params and body
const event = mockPatch({ id: 123 }, { status: "completed" });
// DELETE with route params
const event = mockDelete({ id: 123 });
Factory Pattern
test("lists user's projects", async ({ factories }) => {
// Factories are transaction-bound - auto-rolled back
const user = await factories.user();
const project1 = await factories.project({ ownerId: user.id });
const project2 = await factories.project({ ownerId: user.id });
const event = mockGet({ userId: user.id });
const result = await handler(event);
expect(result).toHaveLength(2);
});
Testing with Related Data
test("returns task with job details", async ({ factories }) => {
// Factories auto-create dependencies
const job = await factories.job(); // Creates project automatically
const task = await factories.task({ jobId: job.id });
const event = mockGet({ id: task.id });
const result = await handler(event);
expect(result.job.id).toBe(job.id);
expect(result.job.project).toBeDefined();
});
Testing Error Cases
test("returns 404 for non-existent resource", async ({ factories: _ }) => {
const event = mockGet({ id: 999999 });
await expectHttpError(handler(event), {
statusCode: 404,
message: "Not found",
});
});
test("returns 400 for invalid input", async ({ factories: _ }) => {
const event = mockPost({}, { invalidField: true });
await expectHttpError(handler(event), { statusCode: 400 });
});
Auto-Import Stubs
The setup file stubs Nuxt/Nitro auto-imports:
| Stub | Purpose |
|------|---------|
| defineEventHandler | Unwraps to return handler directly |
| getUserSession | Returns test user (configurable) |
| useDatabase | Returns test transaction |
| createError | Creates H3-style errors |
| getValidatedQuery | Validates mock query params |
| readValidatedBody | Validates mock body |
| getRouterParam | Returns mock route params |
Key Gotchas
-
Always destructure
factories- Even if unused, it sets up the transaction:test("...", async ({ factories: _ }) => { ... }); -
Don't use top-level db imports - Use the
dbfixture instead:// ❌ Wrong - uses real db, not transaction import { db } from "../utils/db"; // ✅ Right - uses test transaction test("...", async ({ db }) => { ... }); -
Nested transactions work - Code that calls
db.transaction()works because we patch the prototype -
Test file location - Co-locate with handlers:
handler.ts→handler.test.ts -
Separate test database - Always use a dedicated test DB (
myapp-test, notmyapp) -
CI needs PostgreSQL service - See ci-setup.md for GitHub Actions config
Frontend Testing
Test Vue components and pages in Nuxt using @nuxt/test-utils with mountSuspended.
Setup
Dependencies:
yarn add -D @nuxt/test-utils @vue/test-utils happy-dom
Vitest Config - Use environment variable to separate frontend/backend:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { defineVitestConfig } from "@nuxt/test-utils/config";
const isNuxtEnv = process.env.VITEST_ENV === "nuxt";
export default isNuxtEnv
? defineVitestConfig({
test: {
environment: "nuxt",
globals: true,
include: [
"components/**/*.test.ts",
"pages/**/*.test.ts",
"utils/**/*.test.ts",
],
},
})
: defineConfig({
// ... backend config
});
Package.json scripts:
{
"scripts": {
"test": "vitest",
"test:frontend": "VITEST_ENV=nuxt vitest",
"test:frontend:run": "VITEST_ENV=nuxt vitest run"
}
}
Nuxt test config (nuxt.config.test.ts):
export default defineNuxtConfig({
modules: ["@primevue/nuxt-module"], // Include UI libraries
ssr: false, // Disable SSR for simpler component testing
});
File Organization
Co-locate tests with source files:
components/
ProjectCard.vue
ProjectCard.test.ts
pages/
projects/
index.vue
index.test.ts
utils/
dates.ts
dates.test.ts
Component Testing Pattern
Use mountSuspended for async-safe component mounting:
import { describe, it, expect } from "vitest";
import { mountSuspended } from "@nuxt/test-utils/runtime";
import ProjectCard from "./ProjectCard.vue";
describe("ProjectCard", () => {
it("renders project name", async () => {
const wrapper = await mountSuspended(ProjectCard, {
props: {
project: { id: 1, name: "Test Project", status: "active" },
},
});
expect(wrapper.text()).toContain("Test Project");
});
it("shows active badge when active", async () => {
const wrapper = await mountSuspended(ProjectCard, {
props: {
project: { id: 1, name: "Test", status: "active" },
},
});
expect(wrapper.html()).toMatch(/active/i);
});
});
Mocking Composables
Use mockNuxtImport for Nuxt composables:
import { mockNuxtImport } from "@nuxt/test-utils/runtime";
mockNuxtImport("useAddress", () => {
return () => ({
getDisplayAddress: (project: any) =>
project.address || "No address",
});
});
mockNuxtImport("useUserSession", () => {
return () => ({
user: { id: 1, name: "Test User" },
loggedIn: true,
});
});
Mocking API Endpoints
Use registerEndpoint to mock API calls:
import { registerEndpoint } from "@nuxt/test-utils/runtime";
registerEndpoint("/api/projects", {
method: "GET",
handler: () => [
{ id: 1, name: "Project A", status: "active" },
{ id: 2, name: "Project B", status: "completed" },
],
});
registerEndpoint("/api/projects/:id", {
method: "GET",
handler: (event) => ({
id: parseInt(event.context.params.id),
name: "Project Detail",
}),
});
Testing Pages with Routes
import { describe, it, expect } from "vitest";
import { mountSuspended, registerEndpoint } from "@nuxt/test-utils/runtime";
import TaskPage from "./[id].vue";
describe("Task Detail Page", () => {
it("renders task details", async () => {
registerEndpoint("/api/tasks/123", {
method: "GET",
handler: () => ({ id: 123, name: "Fix bug", status: "open" }),
});
const wrapper = await mountSuspended(TaskPage, {
route: {
params: { id: "123" },
},
});
expect(wrapper.text()).toContain("Fix bug");
});
});
Stubbing UI Library Components
For PrimeVue or other UI libraries, stub complex components:
import { mountSuspended } from "@nuxt/test-utils/runtime";
import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
const wrapper = await mountSuspended(MyComponent, {
global: {
plugins: [ToastService, ConfirmationService],
stubs: {
DataTable: true,
Column: true,
Dialog: true,
},
},
});
Testing Utility Functions
For pure utilities, standard Vitest patterns work:
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { formatDate, parseDate } from "./dates";
describe("date utilities", () => {
const originalTZ = process.env.TZ;
afterEach(() => {
process.env.TZ = originalTZ;
});
it("formats date correctly in EST", () => {
process.env.TZ = "America/New_York";
expect(formatDate("2025-01-15")).toBe("Jan 15, 2025");
});
it("formats date correctly in PST", () => {
process.env.TZ = "America/Los_Angeles";
expect(formatDate("2025-01-15")).toBe("Jan 15, 2025");
});
});
Async Handling
import { nextTick } from "vue";
it("updates after user interaction", async () => {
const wrapper = await mountSuspended(Counter);
await wrapper.find("button").trigger("click");
await nextTick();
expect(wrapper.text()).toContain("Count: 1");
});
Frontend Testing Gotchas
-
Use
mountSuspended- Not regularmount. Handles async setup and Nuxt context. -
Mock composables before mounting -
mockNuxtImportmust be called beforemountSuspended. -
Register endpoints before mounting - API mocks must exist before the component fetches.
-
Use
wrapper.text()for content - More reliable than searching for specific elements. -
Await everything -
mountSuspended,trigger(),nextTick()are all async. -
Separate test configs - Use
VITEST_ENVto run frontend and backend tests with different environments.