api-test-suite-generator
SKILL.md
API Test Suite Generator
Generate comprehensive API test suites automatically from your route definitions.
Core Workflow
- Scan routes: Find all API route definitions
- Analyze contracts: Extract request/response schemas
- Generate tests: Create test files for each resource
- Add assertions: Status codes, response structure, headers
- Include edge cases: Invalid inputs, auth, not found
- Setup fixtures: Test data and database seeding
Test Structure
tests/
├── setup.ts # Global test setup
├── fixtures/ # Test data
│ ├── users.ts
│ └── products.ts
├── integration/ # API integration tests
│ ├── users.test.ts
│ ├── products.test.ts
│ └── auth.test.ts
└── helpers/ # Test utilities
├── api-client.ts
└── auth.ts
Test Setup (Vitest/Jest)
// tests/setup.ts
import { beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { createServer } from "../src/server";
import { prisma } from "../src/db";
let server: ReturnType<typeof createServer>;
beforeAll(async () => {
server = await createServer();
await server.listen({ port: 0 }); // Random port
process.env.TEST_BASE_URL = `http://localhost:${server.address().port}`;
});
afterAll(async () => {
await server.close();
await prisma.$disconnect();
});
beforeEach(async () => {
// Clean database before each test
await prisma.$executeRaw`TRUNCATE TABLE users CASCADE`;
});
afterEach(async () => {
// Cleanup after each test
});
export { server };
API Test Client
// tests/helpers/api-client.ts
import supertest from "supertest";
const baseUrl = process.env.TEST_BASE_URL || "http://localhost:3000";
export const api = supertest(baseUrl);
export async function authenticatedApi(token?: string) {
const authToken = token || (await getTestAuthToken());
return {
get: (url: string) => api.get(url).set("Authorization", `Bearer ${authToken}`),
post: (url: string) => api.post(url).set("Authorization", `Bearer ${authToken}`),
put: (url: string) => api.put(url).set("Authorization", `Bearer ${authToken}`),
patch: (url: string) => api.patch(url).set("Authorization", `Bearer ${authToken}`),
delete: (url: string) => api.delete(url).set("Authorization", `Bearer ${authToken}`),
};
}
async function getTestAuthToken(): Promise<string> {
const response = await api.post("/api/auth/login").send({
email: "test@example.com",
password: "testpassword",
});
return response.body.token;
}
Test Generator Script
// scripts/generate-api-tests.ts
import * as fs from "fs";
import * as path from "path";
interface RouteInfo {
method: string;
path: string;
name: string;
params?: { name: string; type: "path" | "query" }[];
requestBody?: object;
responseSchema?: object;
auth?: boolean;
}
interface TestCase {
name: string;
description: string;
method: string;
path: string;
body?: object;
expectedStatus: number;
expectedBody?: object;
headers?: Record<string, string>;
auth?: boolean;
}
function generateTestFile(
resource: string,
routes: RouteInfo[]
): string {
const lines: string[] = [];
// Imports
lines.push(`import { describe, it, expect, beforeEach, afterEach } from "vitest";`);
lines.push(`import { api, authenticatedApi } from "../helpers/api-client";`);
lines.push(`import { create${capitalize(resource)} } from "../fixtures/${resource}";`);
lines.push("");
// Test suite
lines.push(`describe("${capitalize(resource)} API", () => {`);
for (const route of routes) {
const testCases = generateTestCases(route);
lines.push(` describe("${route.method} ${route.path}", () => {`);
for (const testCase of testCases) {
lines.push(generateTestCase(testCase, route));
}
lines.push(` });`);
lines.push("");
}
lines.push(`});`);
return lines.join("\n");
}
function generateTestCases(route: RouteInfo): TestCase[] {
const cases: TestCase[] = [];
// Success case
cases.push({
name: `should ${getActionVerb(route.method)} successfully`,
description: `Happy path for ${route.method} ${route.path}`,
method: route.method,
path: route.path,
body: route.requestBody,
expectedStatus: getExpectedStatus(route.method),
auth: route.auth,
});
// Auth failure case (if auth required)
if (route.auth) {
cases.push({
name: "should return 401 without auth token",
description: "Unauthorized access attempt",
method: route.method,
path: route.path,
expectedStatus: 401,
auth: false,
});
}
// Not found case (if has path params)
if (route.params?.some((p) => p.type === "path")) {
cases.push({
name: "should return 404 for non-existent resource",
description: "Resource not found",
method: route.method,
path: route.path.replace(/:(\w+)/g, "non-existent-id"),
expectedStatus: 404,
auth: route.auth,
});
}
// Validation error case (for POST/PUT/PATCH)
if (["POST", "PUT", "PATCH"].includes(route.method)) {
cases.push({
name: "should return 400 for invalid request body",
description: "Validation failure",
method: route.method,
path: route.path,
body: {},
expectedStatus: 400,
auth: route.auth,
});
}
return cases;
}
function generateTestCase(testCase: TestCase, route: RouteInfo): string {
const lines: string[] = [];
const indent = " ";
lines.push(`${indent}it("${testCase.name}", async () => {`);
// Setup
if (route.params?.some((p) => p.type === "path")) {
lines.push(`${indent} // Setup: Create test resource`);
lines.push(`${indent} const resource = await createTestResource();`);
lines.push(`${indent} const url = "${route.path}".replace(":id", resource.id);`);
} else {
lines.push(`${indent} const url = "${route.path}";`);
}
// Make request
lines.push("");
if (testCase.auth) {
lines.push(`${indent} const client = await authenticatedApi();`);
lines.push(
`${indent} const response = await client.${testCase.method.toLowerCase()}(url)`
);
} else {
lines.push(
`${indent} const response = await api.${testCase.method.toLowerCase()}(url)`
);
}
if (testCase.body) {
lines.push(`${indent} .send(${JSON.stringify(testCase.body, null, 2).replace(/\n/g, `\n${indent} `)})`);
}
lines.push(`${indent} .expect(${testCase.expectedStatus});`);
// Assertions
lines.push("");
if (testCase.expectedStatus < 400) {
lines.push(`${indent} expect(response.body).toBeDefined();`);
if (testCase.method === "POST") {
lines.push(`${indent} expect(response.body.id).toBeDefined();`);
}
} else {
lines.push(`${indent} expect(response.body.error).toBeDefined();`);
}
lines.push(`${indent}});`);
lines.push("");
return lines.join("\n");
}
function getActionVerb(method: string): string {
const verbs: Record<string, string> = {
GET: "retrieve",
POST: "create",
PUT: "update",
PATCH: "partially update",
DELETE: "delete",
};
return verbs[method] || "process";
}
function getExpectedStatus(method: string): number {
const statuses: Record<string, number> = {
GET: 200,
POST: 201,
PUT: 200,
PATCH: 200,
DELETE: 204,
};
return statuses[method] || 200;
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
Example Generated Tests
// tests/integration/users.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { api, authenticatedApi } from "../helpers/api-client";
import { createUser, createUsers } from "../fixtures/users";
describe("Users API", () => {
describe("GET /api/users", () => {
it("should return paginated list of users", async () => {
// Setup
await createUsers(15);
// Request
const client = await authenticatedApi();
const response = await client
.get("/api/users")
.query({ page: 1, limit: 10 })
.expect(200);
// Assertions
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveLength(10);
expect(response.body.meta.total).toBe(15);
expect(response.body.meta.page).toBe(1);
expect(response.body.meta.total_pages).toBe(2);
});
it("should return 401 without auth token", async () => {
const response = await api.get("/api/users").expect(401);
expect(response.body.error.code).toBe("UNAUTHORIZED");
});
});
describe("GET /api/users/:id", () => {
it("should return user by ID", async () => {
const user = await createUser({ name: "Test User" });
const client = await authenticatedApi();
const response = await client
.get(`/api/users/${user.id}`)
.expect(200);
expect(response.body.data.id).toBe(user.id);
expect(response.body.data.name).toBe("Test User");
});
it("should return 404 for non-existent user", async () => {
const client = await authenticatedApi();
const response = await client
.get("/api/users/non-existent-id")
.expect(404);
expect(response.body.error.code).toBe("NOT_FOUND");
});
});
describe("POST /api/users", () => {
it("should create new user", async () => {
const client = await authenticatedApi();
const response = await client
.post("/api/users")
.send({
name: "New User",
email: "new@example.com",
role: "user",
})
.expect(201);
expect(response.body.data.id).toBeDefined();
expect(response.body.data.name).toBe("New User");
expect(response.body.data.email).toBe("new@example.com");
});
it("should return 400 for invalid request body", async () => {
const client = await authenticatedApi();
const response = await client
.post("/api/users")
.send({})
.expect(400);
expect(response.body.error.code).toBe("VALIDATION_ERROR");
expect(response.body.error.details).toBeDefined();
});
it("should return 409 for duplicate email", async () => {
await createUser({ email: "existing@example.com" });
const client = await authenticatedApi();
const response = await client
.post("/api/users")
.send({
name: "New User",
email: "existing@example.com",
})
.expect(409);
expect(response.body.error.code).toBe("CONFLICT");
});
});
describe("PUT /api/users/:id", () => {
it("should update user", async () => {
const user = await createUser({ name: "Original Name" });
const client = await authenticatedApi();
const response = await client
.put(`/api/users/${user.id}`)
.send({
name: "Updated Name",
email: user.email,
})
.expect(200);
expect(response.body.data.name).toBe("Updated Name");
});
it("should return 404 for non-existent user", async () => {
const client = await authenticatedApi();
const response = await client
.put("/api/users/non-existent-id")
.send({ name: "Test" })
.expect(404);
expect(response.body.error.code).toBe("NOT_FOUND");
});
});
describe("DELETE /api/users/:id", () => {
it("should delete user", async () => {
const user = await createUser();
const client = await authenticatedApi();
await client.delete(`/api/users/${user.id}`).expect(204);
// Verify deletion
await client.get(`/api/users/${user.id}`).expect(404);
});
it("should return 404 for non-existent user", async () => {
const client = await authenticatedApi();
await client.delete("/api/users/non-existent-id").expect(404);
});
});
});
Test Fixtures
// tests/fixtures/users.ts
import { prisma } from "../../src/db";
import { faker } from "@faker-js/faker";
interface CreateUserOptions {
name?: string;
email?: string;
role?: string;
}
export async function createUser(options: CreateUserOptions = {}) {
return prisma.user.create({
data: {
name: options.name ?? faker.person.fullName(),
email: options.email ?? faker.internet.email(),
role: options.role ?? "user",
password: await hashPassword("testpassword"),
},
});
}
export async function createUsers(count: number) {
const users = Array.from({ length: count }, () => ({
name: faker.person.fullName(),
email: faker.internet.email(),
role: "user",
password: "hashed-password",
}));
return prisma.user.createMany({ data: users });
}
export async function createAdminUser() {
return createUser({ role: "admin" });
}
Contract Testing
// tests/contract/users.contract.test.ts
import { describe, it, expect } from "vitest";
import { api, authenticatedApi } from "../helpers/api-client";
import { z } from "zod";
// Response schemas
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(["user", "admin"]),
createdAt: z.string().datetime(),
});
const PaginatedUsersSchema = z.object({
success: z.literal(true),
data: z.array(UserSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
total_pages: z.number(),
}),
});
describe("Users API Contract", () => {
it("GET /api/users should match contract", async () => {
const client = await authenticatedApi();
const response = await client.get("/api/users").expect(200);
// Validate against schema
const result = PaginatedUsersSchema.safeParse(response.body);
expect(result.success).toBe(true);
});
it("GET /api/users/:id should match contract", async () => {
const user = await createUser();
const client = await authenticatedApi();
const response = await client.get(`/api/users/${user.id}`).expect(200);
// Validate against schema
const result = UserSchema.safeParse(response.body.data);
expect(result.success).toBe(true);
});
});
CLI Script
#!/usr/bin/env node
// scripts/test-gen.ts
import * as fs from "fs";
import * as path from "path";
import { program } from "commander";
program
.name("test-gen")
.description("Generate API test suite from routes")
.option("-f, --framework <type>", "Framework (express|nextjs|fastify)", "express")
.option("-s, --source <path>", "Source directory", "./src")
.option("-o, --output <path>", "Output directory", "./tests/integration")
.option("-t, --test-runner <type>", "Test runner (vitest|jest)", "vitest")
.parse();
const options = program.opts();
async function main() {
const routes = await scanRoutes(options.framework, options.source);
const groupedRoutes = groupRoutesByResource(routes);
if (!fs.existsSync(options.output)) {
fs.mkdirSync(options.output, { recursive: true });
}
for (const [resource, resourceRoutes] of Object.entries(groupedRoutes)) {
const content = generateTestFile(resource, resourceRoutes);
const filePath = path.join(options.output, `${resource}.test.ts`);
fs.writeFileSync(filePath, content);
console.log(`Generated ${filePath}`);
}
}
main();
Best Practices
- Isolate tests: Each test should be independent
- Clean state: Reset database between tests
- Use fixtures: Create reusable test data factories
- Test edge cases: Invalid input, auth, not found
- Contract testing: Validate response schemas
- Descriptive names: Tests should read like documentation
- Fast execution: Use transactions for database cleanup
- CI integration: Run tests on every PR
Output Checklist
- Test setup with server lifecycle
- API client helper with auth support
- Test files for each resource
- Happy path tests for all endpoints
- Authentication failure tests
- Validation error tests
- Not found tests for path params
- Conflict/duplicate tests where applicable
- Contract/schema validation tests
- Test fixtures for each resource
Weekly Installs
12
Repository
patricio0312rev/skillsFirst Seen
10 days ago
Installed on
claude-code10
opencode9
gemini-cli7
antigravity7
windsurf7
github-copilot7