| name | writing-integration-tests |
| description | Write Vitest integration tests against a real PostgreSQL test database with proper mock setup, seed functions, and assertion patterns. Use when creating tests for API routes, lib modules, or database operations. |
Writing Integration Tests
Creates integration tests that run against a real PostgreSQL test database — never mocked queries.
Test Creation Process
Use the checklist below and track your progress:
Progress:
- [ ] Step 1: Set up the test file structure
- [ ] Step 2: Create seed helpers
- [ ] Step 3: Write test cases
- [ ] Step 4: Run and verify
Step 1: Set up the test file structure
Create the test file at src/app/api/{resource}/__tests__/route.test.ts (for API routes) or src/lib/__tests__/{module}.test.ts (for lib modules).
The setup follows a strict ordering:
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import { DataSource } from "typeorm";
import { getTestDataSource, destroyTestDataSource, cleanDatabase } from "@/test/db-helpers";
import { TeamEntity } from "@/entities/team.entity";
let testDs: DataSource;
let mockCookieStore: Record<string, string> = {};
vi.mock("next/headers", () => ({
cookies: async () => ({
get: (name: string) => {
const value = mockCookieStore[name];
return value !== undefined ? { value } : undefined;
},
}),
}));
vi.mock("@/lib/db", () => ({ getDb: async () => testDs }));
vi.mock("@/lib/github-api", () => ({
fetchSeats: vi.fn(),
}));
const { GET, POST } = await import("@/app/api/teams/route");
beforeAll(async () => { testDs = await getTestDataSource(); });
afterAll(async () => { await destroyTestDataSource(); });
beforeEach(async () => { await cleanDatabase(testDs); mockCookieStore = {}; });
Critical ordering rules:
vi.mock() calls must come before await import() of the module under test
- Use
await import() (dynamic), not static import — this ensures mocks resolve correctly
- Static imports of entities and test helpers are fine (they don't depend on mocks)
Step 2: Create seed helpers
Define per-file seed functions — these are NOT shared across test files:
async function seedTeam(name: string, overrides?: Partial<Team>) {
const repo = testDs.getRepository(TeamEntity);
return repo.save(repo.create({ name, ...overrides }));
}
async function seedConfiguration() {
const repo = testDs.getRepository(ConfigurationEntity);
return repo.save(repo.create({
githubOrg: "test-org",
githubToken: "test-token",
seatBaseCost: 39,
}));
}
For auth-protected routes, define seedAuthSession locally in each test file — it is NOT imported from a shared module:
async function seedAuthSession(): Promise<void> {
const { hashPassword, createSession, SESSION_COOKIE_NAME } = await import("@/lib/auth");
const { UserEntity } = await import("@/entities/user.entity");
const userRepo = testDs.getRepository(UserEntity);
const user = await userRepo.save({
username: "testadmin",
passwordHash: await hashPassword("testpass"),
});
const token = await createSession(user.id);
mockCookieStore[SESSION_COOKIE_NAME] = token;
}
Usage: await seedAuthSession(); — no arguments needed (uses testDs and mockCookieStore from file scope).
Step 3: Write test cases
Cover these scenarios for every endpoint:
| Scenario | Expected |
|---|
| No auth session | 401 |
| Valid request | 200/201 with correct body |
| Invalid body | 400 with error message |
| Missing resource | 404 |
| Unique violation | 409 |
| Invalid ID param | 400 |
describe("GET /api/teams", () => {
it("returns 401 without auth", async () => {
const response = await GET();
expect(response.status).toBe(401);
});
it("returns active teams ordered by name", async () => {
await seedAuthSession();
await seedTeam("Bravo");
await seedTeam("Alpha");
const response = await GET();
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveLength(2);
expect(data[0].name).toBe("Alpha");
});
});
describe("POST /api/teams", () => {
it("creates a new team", async () => {
await seedAuthSession();
const request = new Request("http://localhost/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Team" }),
});
const response = await POST(request);
expect(response.status).toBe(201);
const data = await response.json();
expect(data.name).toBe("New Team");
});
it("returns 409 for duplicate name", async () => {
await seedAuthSession();
await seedTeam("Existing");
const request = new Request("http://localhost/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Existing" }),
});
const response = await POST(request);
expect(response.status).toBe(409);
});
});
For mocking external services:
import { fetchSeats } from "@/lib/github-api";
it("syncs seats from GitHub", async () => {
vi.mocked(fetchSeats).mockResolvedValue([
{ login: "user1", created_at: "2024-01-01" },
]);
});
Step 4: Run and verify
npx vitest run src/app/api/{resource}/__tests__/route.test.ts
Check that:
- All tests pass
- Both happy and error paths are covered
- No test data leaks between tests (each test seeds its own data)
cleanDatabase(testDs) runs in beforeEach
Quick Reference
| Helper | Source | Purpose |
|---|
getTestDataSource() | @/test/db-helpers | Creates test DB connection with synchronize: true |
destroyTestDataSource() | @/test/db-helpers | Closes test DB connection |
cleanDatabase(ds) | @/test/db-helpers | Truncates all tables between tests |
seedAuthSession() | Defined locally in each test file | Seeds user + session, sets cookie in mockCookieStore |
mockCookieStore | Defined locally in each test file | Record<string, string> for cookie mock |
Connected Skills
creating-api-routes — includes basic test scaffolding as part of the route creation workflow
writing-e2e-tests — complement integration tests with full user flow E2E tests