API Test Skill
Test Next.js Route Handlers with real HTTP requests using Playwright. This skill creates comprehensive API tests that verify endpoint behavior, authentication, validation, and error handling.
First Step: Read Project Context
IMPORTANT: Before creating API tests, check .claude/project-context.md for:
- Authentication method (Clerk, NextAuth, JWT, API Key)
- Auth testing pattern (test tokens, session cookies, headers)
- API response format (ActionResponse, custom format)
- Error handling patterns (ServiceError codes, HTTP status mapping)
Also check CLAUDE.md for:
- Server Actions patterns (ActionResponse type)
- Database error handling (ServiceError class)
Context
This skill helps you test:
- Route Handler responses (GET, POST, PUT, DELETE, PATCH)
- Authentication and authorization
- Request validation with Zod schemas
- Error handling and status codes
- Response body structure
- Headers and cookies
Prerequisites
- Next.js dev server running (
npm run dev) - Playwright installed (included in project)
- Test files go in
tests/e2e/api/
Instructions
When the user requests API tests:
1. Analyze the Endpoint
Gather information about:
- HTTP method(s) supported
- Request body schema (if any)
- Query parameters
- Authentication requirements
- Expected response structure
- Error cases to handle
2. Create Test File
File Location: tests/e2e/api/[endpoint-name].spec.ts
Test Template:
import { test, expect } from "@playwright/test";
/**
* API Tests for: [Endpoint Path]
*
* Route Handler: app/api/[path]/route.ts
* Methods: GET, POST, etc.
*
* Authentication: Required / Not Required
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000";
const API_ENDPOINT = "/api/endpoint-name";
test.describe("API: [Endpoint Name]", () => {
test.describe("Authentication", () => {
test("returns 401 for unauthenticated requests", async ({ request }) => {
const response = await request.get(`${BASE_URL}${API_ENDPOINT}`);
expect(response.status()).toBe(401);
const body = await response.json();
expect(body).toHaveProperty("error");
});
});
test.describe("GET requests", () => {
test("returns 200 with valid data", async ({ request }) => {
const response = await request.get(`${BASE_URL}${API_ENDPOINT}`, {
headers: {
// Add auth headers if needed
// "Authorization": `Bearer ${token}`
},
});
expect(response.status()).toBe(200);
const body = await response.json();
// Verify response structure
expect(body).toHaveProperty("data");
expect(Array.isArray(body.data)).toBe(true);
});
test("supports pagination parameters", async ({ request }) => {
const response = await request.get(
`${BASE_URL}${API_ENDPOINT}?page=1&limit=10`,
);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.data.length).toBeLessThanOrEqual(10);
});
});
test.describe("POST requests", () => {
test("creates resource with valid data", async ({ request }) => {
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: {
name: "Test Resource",
description: "Test description",
},
headers: {
"Content-Type": "application/json",
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toHaveProperty("data");
expect(body.data).toHaveProperty("id");
expect(body.data.name).toBe("Test Resource");
});
test("returns 400 for invalid data", async ({ request }) => {
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: {
// Missing required fields
},
headers: {
"Content-Type": "application/json",
},
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty("error");
// Check for validation error details
expect(body).toHaveProperty("details");
});
test("returns 409 for duplicate resource", async ({ request }) => {
// First create
await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: { name: "Unique Name" },
});
// Second create with same data
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: { name: "Unique Name" },
});
expect(response.status()).toBe(409);
});
});
test.describe("Error Handling", () => {
test("returns 404 for non-existent resource", async ({ request }) => {
const response = await request.get(
`${BASE_URL}${API_ENDPOINT}/non-existent-id`,
);
expect(response.status()).toBe(404);
const body = await response.json();
expect(body.error).toContain("not found");
});
test("returns 500 for server errors gracefully", async ({ request }) => {
// Trigger server error condition if possible
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: { triggerError: true },
});
// Should return error, not crash
expect(response.status()).toBeGreaterThanOrEqual(400);
const body = await response.json();
expect(body).toHaveProperty("error");
});
});
});
3. Test Patterns
Testing with Authentication
Check project-context.md for your auth provider and use the appropriate pattern:
import { test, expect } from "@playwright/test";
test.describe("Authenticated API Tests", () => {
let authToken: string;
test.beforeAll(async () => {
// Get auth token from environment or test setup
authToken = process.env.TEST_AUTH_TOKEN!;
});
test("authenticated request succeeds", async ({ request }) => {
const response = await request.get(`${BASE_URL}/api/protected`, {
headers: getAuthHeaders(authToken),
});
expect(response.status()).toBe(200);
});
});
// Auth header helpers - use the one matching your provider
function getAuthHeaders(token: string): Record<string, string> {
// Clerk:
// return { Cookie: `__session=${token}` };
// NextAuth:
// return { Cookie: `next-auth.session-token=${token}` };
// JWT Bearer:
// return { Authorization: `Bearer ${token}` };
// API Key:
// return { 'X-API-Key': token };
// Check project-context.md for your pattern
return { Authorization: `Bearer ${token}` };
}
Testing Request Validation
test.describe("Validation", () => {
const invalidPayloads = [
{ payload: {}, error: "name is required" },
{ payload: { name: "" }, error: "name cannot be empty" },
{ payload: { name: "x".repeat(256) }, error: "name too long" },
{
payload: { name: "valid", amount: "not-a-number" },
error: "amount must be number",
},
];
for (const { payload, error } of invalidPayloads) {
test(`rejects invalid payload: ${error}`, async ({ request }) => {
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: payload,
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toContain(error);
});
}
});
Testing Response Headers
test("returns correct headers", async ({ request }) => {
const response = await request.get(`${BASE_URL}${API_ENDPOINT}`);
expect(response.headers()["content-type"]).toContain("application/json");
expect(response.headers()["cache-control"]).toBeDefined();
});
Testing Rate Limiting (if implemented)
test("enforces rate limiting", async ({ request }) => {
const requests = Array.from({ length: 20 }, () =>
request.get(`${BASE_URL}${API_ENDPOINT}`),
);
const responses = await Promise.all(requests);
const tooManyRequests = responses.filter((r) => r.status() === 429);
expect(tooManyRequests.length).toBeGreaterThan(0);
});
4. Running Tests
# Run all API tests
npm run test:e2e -- tests/e2e/api/
# Run specific endpoint tests
npm run test:e2e -- tests/e2e/api/budgets.spec.ts
# Run with UI mode for debugging
npm run test:e2e:ui -- tests/e2e/api/
# Run in headed mode
npm run test:e2e -- --headed tests/e2e/api/
5. Test Organization
tests/e2e/api/
├── auth/
│ ├── login.spec.ts
│ └── logout.spec.ts
├── budgets/
│ ├── create-budget.spec.ts
│ ├── get-budgets.spec.ts
│ └── update-budget.spec.ts
├── health.spec.ts
└── fixtures/
└── test-data.ts
Common Test Scenarios
Health Check Endpoint
test("health endpoint returns ok", async ({ request }) => {
const response = await request.get(`${BASE_URL}/api/health`);
expect(response.status()).toBe(200);
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.status).toBe("healthy");
});
CRUD Operations
// Adapt endpoint paths and payloads to your domain
test.describe("CRUD: Resources", () => {
let createdId: string;
test("CREATE: POST /api/resources", async ({ request }) => {
const response = await request.post(`${BASE_URL}/api/resources`, {
data: { name: "Test Resource" /* add required fields */ },
});
expect(response.status()).toBe(201);
const { data } = await response.json();
createdId = data.id;
expect(createdId).toBeDefined();
});
test("READ: GET /api/resources/:id", async ({ request }) => {
const response = await request.get(
`${BASE_URL}/api/resources/${createdId}`,
);
expect(response.status()).toBe(200);
const { data } = await response.json();
expect(data.name).toBe("Test Resource");
});
test("UPDATE: PUT /api/resources/:id", async ({ request }) => {
const response = await request.put(
`${BASE_URL}/api/resources/${createdId}`,
{
data: { name: "Updated Resource" },
},
);
expect(response.status()).toBe(200);
const { data } = await response.json();
expect(data.name).toBe("Updated Resource");
});
test("DELETE: DELETE /api/resources/:id", async ({ request }) => {
const response = await request.delete(
`${BASE_URL}/api/resources/${createdId}`,
);
expect(response.status()).toBe(204);
});
});
Testing with Query Parameters
test("filters by status", async ({ request }) => {
const response = await request.get(`${BASE_URL}/api/resources?status=active`);
expect(response.status()).toBe(200);
const { data } = await response.json();
for (const item of data) {
expect(item.status).toBe("active");
}
});
Testing ActionResponse Pattern
If your project uses the ActionResponse pattern (check CLAUDE.md), use these assertions:
// Test successful ActionResponse
test("returns ActionResponse success format", async ({ request }) => {
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: validPayload,
});
expect(response.status()).toBe(200);
const body = await response.json();
// ActionResponse success structure
expect(body).toMatchObject({
success: true,
data: expect.any(Object),
});
expect(body.message).toBeDefined(); // optional
});
// Test failed ActionResponse with validation errors
test("returns fieldErrors on validation failure", async ({ request }) => {
const response = await request.post(`${BASE_URL}${API_ENDPOINT}`, {
data: {}, // missing required fields
});
expect(response.status()).toBe(400);
const body = await response.json();
// ActionResponse error structure
expect(body).toMatchObject({
success: false,
error: expect.any(String),
});
// Check for field-level errors if applicable
if (body.fieldErrors) {
expect(body.fieldErrors).toEqual(
expect.objectContaining({
name: expect.arrayContaining([expect.any(String)]),
}),
);
}
});
// Test database error mapping
test("maps ServiceError to appropriate HTTP status", async ({ request }) => {
const response = await request.get(
`${BASE_URL}${API_ENDPOINT}/non-existent-id`,
);
// ServiceError.notFound → 404
expect(response.status()).toBe(404);
const body = await response.json();
expect(body.success).toBe(false);
expect(body.error).toContain("not found");
});
Best Practices
- Isolate tests: Each test should be independent
- Clean up: Delete test data after tests
- Use fixtures: Share test data setup across tests
- Test edge cases: Empty arrays, null values, special characters
- Verify response shape: Not just status codes
- Test error messages: Ensure they're user-friendly
- Document assumptions: Note any test dependencies
Questions to Ask
When creating API tests:
- What HTTP methods does this endpoint support?
- What authentication is required?
- What does the request body schema look like?
- What are the possible error responses?
- Are there any rate limits or quotas?
- What headers are expected/returned?
- Are there any side effects to test?
Source: janszewczyk/claude-plugins — distributed by TomeVault.