--- name: api-testing description: >- Test REST and GraphQL APIs with Playwright APIRequestContext, Supertest, or standalone HTTP clients. Covers schema validation with Zod/AJV, contract testing patterns, auth flow testing, CRUD lifecycle tests, error response validation, and performance assertions. Use when: "API test," "endpoint test," "REST test," "GraphQL test," "schema validation," "Postman replacement." Related: contract-testing, test-data-management, ci-cd-integration, playwright-automation. license: MIT metadata: author: kindlmann version: "1.0" category: automation --- Test REST and GraphQL APIs with schema validation, auth flow testing, CRUD lifecycle coverage, and performance assertions. Use this skill instead of playwright-automation when the test target is an HTTP endpoint rather than a browser UI. ## Discovery Questions 1. **REST, GraphQL, or both?** REST-only suites use standard HTTP assertions. GraphQL needs query/mutation builders. 2. **Auth mechanism?** JWT, API key, OAuth 2.0, or session cookies -- each needs a different fixture strategy. 3. **OpenAPI/Swagger spec available?** If yes, auto-generate schemas as contracts. 4. **Check `.agents/qa-project-context.md` first.** Respect existing conventions. --- ## Core Principles 1. **Test contracts, not implementations.** Assert on response shape, status codes, and headers -- not on internal logic or database state. 2. **Schema validation catches drift before it breaks consumers.** A failing schema test means you caught a breaking change before your frontend did. 3. **Auth flows are tests too -- don't just hardcode tokens.** Test login, refresh, expiration, and permission boundaries. 4. **Response time is a testable assertion.** Performance regressions caught in CI are cheaper than production incidents. --- ## Playwright API Testing `APIRequestContext` supports standalone API tests without launching a browser and shares cookie/storage state with browser contexts. ### Configuration and Standalone Tests ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './api-tests', use: { baseURL: process.env.API_BASE_URL ?? 'http://localhost:3000', extraHTTPHeaders: { 'Accept': 'application/json' }, }, }); ``` ```typescript import { test, expect } from '@playwright/test'; test.describe('Users API', () => { test('GET /api/users returns a list', async ({ request }) => { const response = await request.get('/api/users'); expect(response.status()).toBe(200); expect(response.headers()['content-type']).toContain('application/json'); const body = await response.json(); expect(body.users).toBeInstanceOf(Array); expect(body.users[0]).toHaveProperty('id'); expect(body.users[0]).toHaveProperty('email'); }); test('GET /api/users/:id returns 404 for missing user', async ({ request }) => { const response = await request.get('/api/users/non-existent-id'); expect(response.status()).toBe(404); }); }); ``` ### Combined Browser + API Tests ```typescript test('project created via API appears in dashboard', async ({ request, page }) => { const createRes = await request.post('/api/projects', { data: { name: 'API-Created Project', description: 'Seeded via API' }, }); expect(createRes.ok()).toBeTruthy(); const project = await createRes.json(); await page.goto('/dashboard'); await expect(page.getByText('API-Created Project')).toBeVisible(); await request.delete(`/api/projects/${project.id}`); // cleanup }); ``` ### Authenticated API Fixture ```typescript // fixtures/api.fixture.ts import { test as base, expect, APIRequestContext } from '@playwright/test'; export const test = base.extend<{ authedApi: APIRequestContext }>({ authedApi: async ({ playwright }, use) => { const api = await playwright.request.newContext({ baseURL: process.env.API_BASE_URL ?? 'http://localhost:3000', extraHTTPHeaders: { 'Accept': 'application/json' }, }); const loginRes = await api.post('/api/auth/login', { data: { email: process.env.TEST_USER_EMAIL!, password: process.env.TEST_USER_PASSWORD! }, }); expect(loginRes.ok()).toBeTruthy(); await use(api); await api.dispose(); }, }); export { expect }; ``` --- ## Schema Validation ### Zod ```typescript import { z } from 'zod'; import { test, expect } from '@playwright/test'; const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), role: z.enum(['admin', 'member', 'viewer']), createdAt: z.string().datetime(), }); const UsersListSchema = z.object({ users: z.array(UserSchema), total: z.number().int().nonneg(), page: z.number().int().positive(), pageSize: z.number().int().positive(), }); test('GET /api/users matches schema', async ({ request }) => { const response = await request.get('/api/users'); const result = UsersListSchema.safeParse(await response.json()); if (!result.success) console.error('Schema errors:', result.error.issues); expect(result.success).toBe(true); }); ``` ### AJV with JSON Schema ```typescript import Ajv from 'ajv'; import addFormats from 'ajv-formats'; const ajv = new Ajv({ allErrors: true }); addFormats(ajv); const userSchema = { type: 'object', required: ['id', 'email', 'name', 'role'], properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string', format: 'email' }, name: { type: 'string', minLength: 1 }, role: { type: 'string', enum: ['admin', 'member', 'viewer'] }, }, additionalProperties: false, }; test('GET /api/users/:id conforms to JSON Schema', async ({ request }) => { const body = await (await request.get('/api/users/some-valid-id')).json(); expect(ajv.compile(userSchema)(body)).toBe(true); }); ``` ### Schema-as-Contract Pattern Both API and tests import the same schema file. If the response shape changes, consumer tests fail immediately. With an OpenAPI spec, auto-generate via `json-schema-to-zod`. ```typescript // shared/schemas/user.schema.ts (imported by both API and tests) import { z } from 'zod'; export const UserResponseSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string(), role: z.enum(['admin', 'member', 'viewer']), createdAt: z.string().datetime(), }); export type UserResponse = z.infer; ``` --- ## Test Patterns ### CRUD Lifecycle Test ```typescript import { test, expect } from '../fixtures/api.fixture'; test.describe.serial('Projects CRUD lifecycle', () => { let projectId: string; test('CREATE', async ({ authedApi }) => { const res = await authedApi.post('/api/projects', { data: { name: 'Lifecycle Project', description: 'CRUD test' }, }); expect(res.status()).toBe(201); const body = await res.json(); expect(body).toHaveProperty('id'); projectId = body.id; }); test('READ', async ({ authedApi }) => { const res = await authedApi.get(`/api/projects/${projectId}`); expect(res.status()).toBe(200); expect((await res.json()).name).toBe('Lifecycle Project'); }); test('UPDATE', async ({ authedApi }) => { const res = await authedApi.patch(`/api/projects/${projectId}`, { data: { name: 'Updated Name' }, }); expect(res.status()).toBe(200); expect((await res.json()).name).toBe('Updated Name'); }); test('DELETE', async ({ authedApi }) => { expect((await authedApi.delete(`/api/projects/${projectId}`)).status()).toBe(204); }); test('VERIFY DELETED', async ({ authedApi }) => { expect((await authedApi.get(`/api/projects/${projectId}`)).status()).toBe(404); }); }); ``` ### Auth Flow Testing ```typescript test.describe('Authentication flows', () => { test('successful login returns tokens', async ({ request }) => { const res = await request.post('/api/auth/login', { data: { email: 'user@example.com', password: 'correct-password' }, }); expect(res.status()).toBe(200); const body = await res.json(); expect(body).toHaveProperty('accessToken'); expect(body).toHaveProperty('refreshToken'); }); test('invalid credentials return 401', async ({ request }) => { const res = await request.post('/api/auth/login', { data: { email: 'user@example.com', password: 'wrong' }, }); expect(res.status()).toBe(401); }); test('expired token returns 401', async ({ request }) => { const res = await request.get('/api/users/me', { headers: { Authorization: 'Bearer expired-token-here' }, }); expect(res.status()).toBe(401); }); test('token refresh provides new access token', async ({ request }) => { const { refreshToken } = await (await request.post('/api/auth/login', { data: { email: 'user@example.com', password: 'correct-password' }, })).json(); const refreshRes = await request.post('/api/auth/refresh', { data: { refreshToken } }); expect(refreshRes.status()).toBe(200); expect((await refreshRes.json())).toHaveProperty('accessToken'); }); test('insufficient permissions return 403', async ({ request }) => { const { accessToken } = await (await request.post('/api/auth/login', { data: { email: 'viewer@example.com', password: 'viewer-password' }, })).json(); const res = await request.delete('/api/admin/users/some-id', { headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.status()).toBe(403); }); }); ``` ### Error Response Validation ```typescript test.describe('Error responses', () => { test('400 - malformed request body', async ({ request }) => { const res = await request.post('/api/projects', { data: { name: '' } }); expect(res.status()).toBe(400); const body = await res.json(); expect(body.details).toEqual( expect.arrayContaining([expect.objectContaining({ field: 'name' })]), ); }); test('422 - validation error with field details', async ({ request }) => { const res = await request.post('/api/users', { data: { email: 'not-an-email', name: 'Test' } }); expect(res.status()).toBe(422); expect((await res.json()).details).toEqual( expect.arrayContaining([expect.objectContaining({ field: 'email' })]), ); }); test('429 - rate limiting returns retry-after header', async ({ request }) => { const responses = await Promise.all( Array.from({ length: 20 }, () => request.get('/api/status')), ); const rateLimited = responses.find(r => r.status() === 429); if (rateLimited) { expect(rateLimited.headers()['retry-after']).toBeDefined(); } }); }); ``` ### Pagination Testing ```typescript test('first page returns correct metadata', async ({ request }) => { const body = await (await request.get('/api/projects?page=1&pageSize=10')).json(); expect(body.page).toBe(1); expect(body.items.length).toBeLessThanOrEqual(10); expect(body.total).toBeGreaterThanOrEqual(body.items.length); }); test('out of bounds page returns empty items', async ({ request }) => { const body = await (await request.get('/api/projects?page=99999&pageSize=10')).json(); expect(body.items).toHaveLength(0); }); test('invalid page size is rejected', async ({ request }) => { expect((await request.get('/api/projects?page=1&pageSize=0')).status()).toBe(400); }); ``` ### File Upload/Download via API ```typescript test('upload via multipart form', async ({ request }) => { const res = await request.post('/api/files/upload', { multipart: { file: { name: 'sample.csv', mimeType: 'text/csv', buffer: Buffer.from('id,name\n1,Test') }, }, }); expect(res.status()).toBe(201); expect((await res.json()).fileName).toBe('sample.csv'); }); test('download and verify headers', async ({ request }) => { const res = await request.get('/api/files/some-file-id/download'); expect(res.headers()['content-disposition']).toContain('attachment'); }); ``` ### GraphQL Testing ```typescript test.describe('GraphQL API', () => { const gql = (request: any, query: string, variables?: Record) => request.post('/graphql', { data: { query, variables } }); test('query - fetches user by ID', async ({ request }) => { const body = await (await gql(request, ` query GetUser($id: ID!) { user(id: $id) { id email name } } `, { id: 'user-1' })).json(); expect(body.errors).toBeUndefined(); expect(body.data.user).toMatchObject({ id: 'user-1', email: expect.any(String) }); }); test('mutation - creates a project', async ({ request }) => { const body = await (await gql(request, ` mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { id name } } `, { input: { name: 'GQL Project' } })).json(); expect(body.errors).toBeUndefined(); expect(body.data.createProject.name).toBe('GQL Project'); }); test('invalid query returns errors array', async ({ request }) => { const body = await (await gql(request, `query { nonExistentField }`)).json(); expect(body.errors).toBeDefined(); expect(body.errors[0]).toHaveProperty('message'); }); }); ``` ### Webhook Testing ```typescript import http from 'http'; test.describe('Webhook delivery', () => { let server: http.Server; let payloads: any[] = []; let webhookUrl: string; test.beforeAll(async () => { server = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { payloads.push(JSON.parse(body)); res.writeHead(200).end(); }); }); await new Promise((r) => server.listen(0, r)); webhookUrl = `http://localhost:${(server.address() as any).port}`; }); test.afterAll(() => server?.close()); test('receives event on project creation', async ({ request }) => { const { id: hookId } = await (await request.post('/api/webhooks', { data: { url: webhookUrl, events: ['project.created'] }, })).json(); await request.post('/api/projects', { data: { name: 'Webhook Project' } }); await new Promise((r) => setTimeout(r, 2000)); expect(payloads.at(-1).event).toBe('project.created'); await request.delete(`/api/webhooks/${hookId}`); }); }); ``` --- ## Performance Assertions ```typescript test('GET /api/users responds within 500ms', async ({ request }) => { const start = Date.now(); const res = await request.get('/api/users'); expect(res.ok()).toBeTruthy(); expect(Date.now() - start).toBeLessThan(500); }); test('response payload stays under 1MB', async ({ request }) => { const body = await (await request.get('/api/users')).body(); expect(body.length / 1024).toBeLessThan(1024); }); test('handles 50 concurrent requests without errors', async ({ request }) => { const results = await Promise.all( Array.from({ length: 50 }, () => request.get('/api/status').then(r => r.status())), ); expect(results.every(s => s >= 200 && s < 500)).toBe(true); }); ``` --- ## Anti-Patterns ### 1. Hardcoded auth tokens Tokens expire, rotate, and differ across environments. Use a login fixture that acquires tokens dynamically. ### 2. Testing against production API tests create, modify, and delete data. Run against a dedicated test environment or local instance. ### 3. Not validating error responses Happy-path-only suites miss the most common production issues. Test 400, 401, 403, 404, and 500 responses for every endpoint. ### 4. Ignoring response headers Headers carry cache directives, rate limit info, content type, and CORS policy. If your API sets them, assert on them. ### 5. No cleanup after test data creation Tests that create resources without deleting them pollute the database. Use `afterEach`/`afterAll` hooks or fixture teardown. ### 6. Treating API tests as unit tests Don't mock the database -- API tests verify the contract from the consumer's perspective. ### 7. Ignoring idempotency PUT and DELETE should be idempotent. Test that calling them twice produces the same result. --- ## Done When - Every target endpoint has at least a happy-path test and at least one error-path test (4xx or 5xx response validated) - Auth flow tested as its own describe block: successful login, invalid credentials, expired token, and permission boundary (403) - Schema validation assertions on response shape using Zod or AJV — not just `toHaveProperty` spot-checks - Contract tests in place for any endpoint consumed by a different team or service (shared schema file or Pact) - Test suite runs cleanly in CI without any external service dependencies — all third-party calls mocked or virtualized ## Related Skills - **playwright-automation** -- Browser-based E2E testing, Page Object Model, and combined browser + API patterns. - **ci-cd-integration** -- Running API test suites in CI pipelines, parallelization, and environment management. - **test-strategy** -- Deciding what to test at the API layer vs. unit vs. E2E. - **self-healing-tests** -- Reducing maintenance burden when API contracts evolve.