Agent Skills: Nuxt / Nitro Testing Patterns

Test Nuxt 3 / Nitro applications - both API handlers (real PostgreSQL, transaction rollback) and frontend components (@nuxt/test-utils, mountSuspended).

UncategorizedID: gallop-systems/claude-skills/nitro-testing

Install this agent skill to your local

pnpm dlx add-skill https://github.com/gallop-systems/claude-skills/tree/HEAD/skills/nitro-testing

Skill Files

Browse the full folder contents for nitro-testing.

Download Skill

Loading file tree…

skills/nitro-testing/SKILL.md

Skill Metadata

Name
nitro-testing
Description
Test Nuxt 3 / Nitro applications - both API handlers (real PostgreSQL, transaction rollback) and frontend components (@nuxt/test-utils, mountSuspended).

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):

Frontend (Components/Pages):

Example Files

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

  1. Always destructure factories - Even if unused, it sets up the transaction:

    test("...", async ({ factories: _ }) => { ... });
    
  2. Don't use top-level db imports - Use the db fixture instead:

    // ❌ Wrong - uses real db, not transaction
    import { db } from "../utils/db";
    
    // ✅ Right - uses test transaction
    test("...", async ({ db }) => { ... });
    
  3. Nested transactions work - Code that calls db.transaction() works because we patch the prototype

  4. Test file location - Co-locate with handlers: handler.tshandler.test.ts

  5. Separate test database - Always use a dedicated test DB (myapp-test, not myapp)

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

  1. Use mountSuspended - Not regular mount. Handles async setup and Nuxt context.

  2. Mock composables before mounting - mockNuxtImport must be called before mountSuspended.

  3. Register endpoints before mounting - API mocks must exist before the component fetches.

  4. Use wrapper.text() for content - More reliable than searching for specific elements.

  5. Await everything - mountSuspended, trigger(), nextTick() are all async.

  6. Separate test configs - Use VITEST_ENV to run frontend and backend tests with different environments.