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/plugins/nitro-testing/skills/nitro-testing

Skill Files

Browse the full folder contents for nitro-testing.

Download Skill

Loading file tree…

plugins/nitro-testing/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.

Contributing Back

This skill grows by capturing what it missed. If you just worked through something in this domain that this skill did not cover — an error you had to figure out, a behavior that contradicts what is documented above, a workflow knot — ask the user: "Want me to contribute this back to the nitro-testing skill?"

If yes, run /contribute-skill. If that command is not available, do the equivalent inline: distill the generic lesson (placeholders only — no project names, IDs, domains, or secrets), then branch or fork gallop-systems/agent-skills and open a PR editing this skill.