# API Testing > **When to use**: Testing REST or GraphQL APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead. > **Prerequisites**: [core/configuration.md](configuration.md) for `baseURL` setup, [core/fixtures-and-hooks.md](fixtures-and-hooks.md) for custom fixture patterns. ## Quick Reference ```typescript // Standalone API test — no browser launched import { test, expect } from '@playwright/test'; test('GET /api/users returns user 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).toHaveLength(3); expect(body.users[0]).toMatchObject({ id: expect.any(Number), email: expect.any(String) }); }); ``` ## Patterns ### APIRequestContext Basics **Use when**: Making HTTP requests in any test — GET, POST, PUT, PATCH, DELETE with headers, query params, and request bodies. **Avoid when**: You need to test browser-rendered responses (redirects, cookies set via `Set-Cookie` with `HttpOnly`). Use a browser test instead. The `request` fixture provides a pre-configured `APIRequestContext` that inherits `baseURL` from your config. No browser is launched. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('CRUD operations via API', async ({ request }) => { // GET with query parameters const listResponse = await request.get('/api/users', { params: { page: 1, limit: 10, role: 'admin' }, }); expect(listResponse.ok()).toBeTruthy(); // POST with JSON body const createResponse = await request.post('/api/users', { data: { name: 'Jane Doe', email: 'jane@example.com', role: 'editor', }, }); expect(createResponse.status()).toBe(201); const created = await createResponse.json(); // PUT — full replacement const updateResponse = await request.put(`/api/users/${created.id}`, { data: { name: 'Jane Smith', email: 'jane.smith@example.com', role: 'editor', }, }); expect(updateResponse.ok()).toBeTruthy(); // PATCH — partial update const patchResponse = await request.patch(`/api/users/${created.id}`, { data: { role: 'admin' }, }); expect(patchResponse.ok()).toBeTruthy(); const patched = await patchResponse.json(); expect(patched.role).toBe('admin'); // DELETE const deleteResponse = await request.delete(`/api/users/${created.id}`); expect(deleteResponse.status()).toBe(204); // Verify deletion const getDeleted = await request.get(`/api/users/${created.id}`); expect(getDeleted.status()).toBe(404); }); test('custom headers and auth tokens', async ({ request }) => { const response = await request.get('/api/protected/resource', { headers: { 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...', 'X-Request-ID': 'test-correlation-id-123', 'Accept': 'application/json', }, }); expect(response.ok()).toBeTruthy(); }); test('form-urlencoded body', async ({ request }) => { const response = await request.post('/api/oauth/token', { form: { grant_type: 'client_credentials', client_id: 'my-app', client_secret: 'secret-value', }, }); expect(response.ok()).toBeTruthy(); const token = await response.json(); expect(token).toHaveProperty('access_token'); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('CRUD operations via API', async ({ request }) => { const listResponse = await request.get('/api/users', { params: { page: 1, limit: 10, role: 'admin' }, }); expect(listResponse.ok()).toBeTruthy(); const createResponse = await request.post('/api/users', { data: { name: 'Jane Doe', email: 'jane@example.com', role: 'editor', }, }); expect(createResponse.status()).toBe(201); const created = await createResponse.json(); const updateResponse = await request.put(`/api/users/${created.id}`, { data: { name: 'Jane Smith', email: 'jane.smith@example.com', role: 'editor', }, }); expect(updateResponse.ok()).toBeTruthy(); const patchResponse = await request.patch(`/api/users/${created.id}`, { data: { role: 'admin' }, }); expect(patchResponse.ok()).toBeTruthy(); const deleteResponse = await request.delete(`/api/users/${created.id}`); expect(deleteResponse.status()).toBe(204); }); test('form-urlencoded body', async ({ request }) => { const response = await request.post('/api/oauth/token', { form: { grant_type: 'client_credentials', client_id: 'my-app', client_secret: 'secret-value', }, }); expect(response.ok()).toBeTruthy(); const token = await response.json(); expect(token).toHaveProperty('access_token'); }); ``` ### API Test Structure **Use when**: Writing dedicated API test suites that do not need a browser. **Avoid when**: You need to assert on UI state after an API call — use a combined test with `page` and `request` fixtures. Structure API tests in their own directory with descriptive `describe` blocks per resource or domain. **TypeScript** ```typescript // tests/api/users.spec.ts import { test, expect } from '@playwright/test'; // No browser is launched — these tests use only the request fixture test.describe('Users API', () => { test.describe('GET /api/users', () => { test('returns paginated user list', async ({ request }) => { const response = await request.get('/api/users', { params: { page: 1, limit: 5 }, }); expect(response.status()).toBe(200); const body = await response.json(); expect(body.users.length).toBeLessThanOrEqual(5); expect(body.pagination).toMatchObject({ page: 1, limit: 5, total: expect.any(Number), }); }); test('filters by role', async ({ request }) => { const response = await request.get('/api/users', { params: { role: 'admin' }, }); const body = await response.json(); for (const user of body.users) { expect(user.role).toBe('admin'); } }); }); test.describe('POST /api/users', () => { test('creates a new user with valid data', async ({ request }) => { const response = await request.post('/api/users', { data: { name: 'Test User', email: `test-${Date.now()}@example.com` }, }); expect(response.status()).toBe(201); const user = await response.json(); expect(user).toMatchObject({ id: expect.any(Number), name: 'Test User', }); }); test('rejects duplicate email', async ({ request }) => { const email = `dupe-${Date.now()}@example.com`; await request.post('/api/users', { data: { name: 'First', email } }); const response = await request.post('/api/users', { data: { name: 'Second', email }, }); expect(response.status()).toBe(409); const body = await response.json(); expect(body.error).toContain('already exists'); }); }); }); ``` **JavaScript** ```javascript // tests/api/users.spec.js const { test, expect } = require('@playwright/test'); test.describe('Users API', () => { test.describe('GET /api/users', () => { test('returns paginated user list', async ({ request }) => { const response = await request.get('/api/users', { params: { page: 1, limit: 5 }, }); expect(response.status()).toBe(200); const body = await response.json(); expect(body.users.length).toBeLessThanOrEqual(5); expect(body.pagination).toMatchObject({ page: 1, limit: 5, total: expect.any(Number), }); }); }); test.describe('POST /api/users', () => { test('creates a new user with valid data', async ({ request }) => { const response = await request.post('/api/users', { data: { name: 'Test User', email: `test-${Date.now()}@example.com` }, }); expect(response.status()).toBe(201); const user = await response.json(); expect(user).toMatchObject({ id: expect.any(Number), name: 'Test User', }); }); }); }); ``` **Config tip**: Use a dedicated project for API tests to avoid launching browsers. ```typescript // playwright.config.ts — API project runs without a browser import { defineConfig } from '@playwright/test'; export default defineConfig({ projects: [ { name: 'api', testDir: './tests/api', use: { baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Accept': 'application/json', }, }, }, { name: 'e2e', testDir: './tests/e2e', use: { baseURL: 'https://app.example.com', browserName: 'chromium', }, }, ], }); ``` ### Request Fixtures **Use when**: Multiple tests need an authenticated API client, or you want to share request configuration (headers, base URL, auth tokens) across a test suite. **Avoid when**: A single test makes one-off API calls. Use the built-in `request` fixture directly. **TypeScript** ```typescript // fixtures/api-fixtures.ts import { test as base, expect, APIRequestContext } from '@playwright/test'; type ApiFixtures = { authenticatedRequest: APIRequestContext; adminRequest: APIRequestContext; }; export const test = base.extend({ authenticatedRequest: async ({ playwright }, use) => { // Create a fresh context with auth headers const context = await playwright.request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Authorization': `Bearer ${process.env.API_TOKEN}`, 'Accept': 'application/json', }, }); await use(context); await context.dispose(); }, adminRequest: async ({ playwright }, use) => { // Login via API to get a token, then create authenticated context const loginContext = await playwright.request.newContext({ baseURL: 'https://api.example.com', }); const loginResponse = await loginContext.post('/api/auth/login', { data: { email: process.env.ADMIN_EMAIL, password: process.env.ADMIN_PASSWORD, }, }); expect(loginResponse.ok()).toBeTruthy(); const { token } = await loginResponse.json(); await loginContext.dispose(); const context = await playwright.request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }); await use(context); await context.dispose(); }, }); export { expect }; ``` ```typescript // tests/api/admin.spec.ts import { test, expect } from '../../fixtures/api-fixtures'; test('admin can list all users', async ({ adminRequest }) => { const response = await adminRequest.get('/api/admin/users'); expect(response.status()).toBe(200); const body = await response.json(); expect(body.users.length).toBeGreaterThan(0); }); test('admin can delete a user', async ({ adminRequest }) => { // Create then delete const createResp = await adminRequest.post('/api/users', { data: { name: 'To Delete', email: `del-${Date.now()}@example.com` }, }); const { id } = await createResp.json(); const deleteResp = await adminRequest.delete(`/api/users/${id}`); expect(deleteResp.status()).toBe(204); }); ``` **JavaScript** ```javascript // fixtures/api-fixtures.js const { test: base, expect } = require('@playwright/test'); const test = base.extend({ authenticatedRequest: async ({ playwright }, use) => { const context = await playwright.request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Authorization': `Bearer ${process.env.API_TOKEN}`, 'Accept': 'application/json', }, }); await use(context); await context.dispose(); }, adminRequest: async ({ playwright }, use) => { const loginContext = await playwright.request.newContext({ baseURL: 'https://api.example.com', }); const loginResponse = await loginContext.post('/api/auth/login', { data: { email: process.env.ADMIN_EMAIL, password: process.env.ADMIN_PASSWORD, }, }); expect(loginResponse.ok()).toBeTruthy(); const { token } = await loginResponse.json(); await loginContext.dispose(); const context = await playwright.request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }); await use(context); await context.dispose(); }, }); module.exports = { test, expect }; ``` ```javascript // tests/api/admin.spec.js const { test, expect } = require('../../fixtures/api-fixtures'); test('admin can list all users', async ({ adminRequest }) => { const response = await adminRequest.get('/api/admin/users'); expect(response.status()).toBe(200); const body = await response.json(); expect(body.users.length).toBeGreaterThan(0); }); ``` ### JSON Response Assertions **Use when**: Validating response status, headers, and body structure after every API call. **Avoid when**: Never skip these. Every API test should assert on status and body. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('thorough response validation', async ({ request }) => { const response = await request.get('/api/users/42'); // Status code — always check first expect(response.status()).toBe(200); // Status category — ok() checks 200-299 range expect(response.ok()).toBeTruthy(); // Response headers expect(response.headers()['content-type']).toContain('application/json'); expect(response.headers()['x-request-id']).toBeDefined(); expect(response.headers()['cache-control']).toMatch(/max-age=\d+/); // Full body parse and deep assertion const user = await response.json(); // Exact match on known fields expect(user.id).toBe(42); expect(user.name).toBe('Jane Doe'); expect(user.email).toBe('jane@example.com'); // Partial match — ignore fields you don't care about expect(user).toMatchObject({ id: 42, name: 'Jane Doe', role: expect.stringMatching(/^(admin|editor|viewer)$/), }); // Type checks with expect.any() expect(user).toMatchObject({ id: expect.any(Number), name: expect.any(String), createdAt: expect.any(String), permissions: expect.any(Array), }); // Array content expect(user.permissions).toEqual( expect.arrayContaining(['read', 'write']) ); expect(user.permissions).not.toContain('delete'); // Nested object assertions expect(user.profile).toMatchObject({ avatar: expect.stringMatching(/^https:\/\//), bio: expect.any(String), }); // Date format validation expect(new Date(user.createdAt).toISOString()).toBe(user.createdAt); }); test('list response structure', async ({ request }) => { const response = await request.get('/api/users'); const body = await response.json(); // Array length expect(body.users).toHaveLength(10); // Every item in array matches shape for (const user of body.users) { expect(user).toMatchObject({ id: expect.any(Number), name: expect.any(String), email: expect.stringContaining('@'), }); } // Pagination metadata expect(body.pagination).toEqual({ page: 1, limit: 10, total: expect.any(Number), totalPages: expect.any(Number), }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('thorough response validation', async ({ request }) => { const response = await request.get('/api/users/42'); expect(response.status()).toBe(200); expect(response.ok()).toBeTruthy(); expect(response.headers()['content-type']).toContain('application/json'); const user = await response.json(); expect(user).toMatchObject({ id: 42, name: 'Jane Doe', role: expect.stringMatching(/^(admin|editor|viewer)$/), }); expect(user).toMatchObject({ id: expect.any(Number), name: expect.any(String), createdAt: expect.any(String), permissions: expect.any(Array), }); expect(user.permissions).toEqual( expect.arrayContaining(['read', 'write']) ); }); test('list response structure', async ({ request }) => { const response = await request.get('/api/users'); const body = await response.json(); expect(body.users).toHaveLength(10); for (const user of body.users) { expect(user).toMatchObject({ id: expect.any(Number), name: expect.any(String), email: expect.stringContaining('@'), }); } }); ``` ### GraphQL Testing **Use when**: Your backend exposes a GraphQL API and you want to test queries, mutations, variables, and error handling. **Avoid when**: Your API is purely REST. Use the standard HTTP methods instead. All GraphQL requests go through `POST` to a single endpoint. Send `query`, `variables`, and optionally `operationName` in the JSON body. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; const GRAPHQL_ENDPOINT = '/graphql'; test.describe('GraphQL API', () => { test('query with variables', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` query GetUser($id: ID!) { user(id: $id) { id name email posts { id title } } } `, variables: { id: '42' }, }, }); expect(response.ok()).toBeTruthy(); const { data, errors } = await response.json(); // GraphQL returns 200 even on errors — always check both expect(errors).toBeUndefined(); expect(data.user).toMatchObject({ id: '42', name: expect.any(String), email: expect.stringContaining('@'), }); expect(data.user.posts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), title: expect.any(String) }), ]) ); }); test('mutation creates a resource', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id title status author { id } } } `, variables: { input: { title: 'API Testing with Playwright', body: 'A comprehensive guide...', status: 'DRAFT', }, }, }, }); const { data, errors } = await response.json(); expect(errors).toBeUndefined(); expect(data.createPost).toMatchObject({ id: expect.any(String), title: 'API Testing with Playwright', status: 'DRAFT', }); }); test('handles GraphQL validation errors', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } } `, variables: { input: { title: '' }, // invalid: empty title }, }, }); // GraphQL often returns 200 even for validation errors const { data, errors } = await response.json(); expect(errors).toBeDefined(); expect(errors.length).toBeGreaterThan(0); expect(errors[0].message).toContain('title'); expect(errors[0].extensions?.code).toBe('BAD_USER_INPUT'); }); test('handles authorization errors', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` query AdminDashboard { adminStats { totalRevenue activeUsers } } `, }, // No auth header }); const { data, errors } = await response.json(); expect(errors).toBeDefined(); expect(errors[0].extensions?.code).toBe('UNAUTHORIZED'); expect(data?.adminStats).toBeNull(); }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const GRAPHQL_ENDPOINT = '/graphql'; test.describe('GraphQL API', () => { test('query with variables', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` query GetUser($id: ID!) { user(id: $id) { id name email } } `, variables: { id: '42' }, }, }); const { data, errors } = await response.json(); expect(errors).toBeUndefined(); expect(data.user).toMatchObject({ id: '42', name: expect.any(String), email: expect.stringContaining('@'), }); }); test('mutation creates a resource', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id title status } } `, variables: { input: { title: 'API Testing with Playwright', body: 'A comprehensive guide...', status: 'DRAFT', }, }, }, }); const { data, errors } = await response.json(); expect(errors).toBeUndefined(); expect(data.createPost).toMatchObject({ id: expect.any(String), title: 'API Testing with Playwright', status: 'DRAFT', }); }); test('handles GraphQL validation errors', async ({ request }) => { const response = await request.post(GRAPHQL_ENDPOINT, { data: { query: ` mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } } `, variables: { input: { title: '' } }, }, }); const { data, errors } = await response.json(); expect(errors).toBeDefined(); expect(errors.length).toBeGreaterThan(0); expect(errors[0].message).toContain('title'); }); }); ``` ### API Data Seeding **Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup. **Avoid when**: The test specifically validates the creation flow through the UI. Seed everything *except* what you are testing. **TypeScript** ```typescript import { test as base, expect, APIRequestContext } from '@playwright/test'; // Fixture that seeds data via API before each test type SeedFixtures = { seedUser: { id: number; email: string; password: string }; seedProject: { id: number; name: string }; }; export const test = base.extend({ seedUser: async ({ request }, use) => { const email = `user-${Date.now()}@example.com`; const password = 'TestPass123!'; // Create via API const response = await request.post('/api/users', { data: { name: 'Test User', email, password }, }); expect(response.ok()).toBeTruthy(); const user = await response.json(); // Pass to test await use({ id: user.id, email, password }); // Cleanup after test — always delete what you created await request.delete(`/api/users/${user.id}`); }, seedProject: async ({ request, seedUser }, use) => { const response = await request.post('/api/projects', { data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id }, }); expect(response.ok()).toBeTruthy(); const project = await response.json(); await use({ id: project.id, name: project.name }); await request.delete(`/api/projects/${project.id}`); }, }); export { expect }; ``` ```typescript // tests/e2e/project-dashboard.spec.ts import { test, expect } from '../../fixtures/seed-fixtures'; test('user sees their project on dashboard', async ({ page, seedUser, seedProject }) => { // Login via UI (or use storageState for speed) await page.goto('/login'); await page.getByLabel('Email').fill(seedUser.email); await page.getByLabel('Password').fill(seedUser.password); await page.getByRole('button', { name: 'Sign in' }).click(); // Data already exists — go straight to assertion await page.waitForURL('/dashboard'); await expect(page.getByRole('heading', { name: seedProject.name })).toBeVisible(); }); ``` **JavaScript** ```javascript // fixtures/seed-fixtures.js const { test: base, expect } = require('@playwright/test'); const test = base.extend({ seedUser: async ({ request }, use) => { const email = `user-${Date.now()}@example.com`; const password = 'TestPass123!'; const response = await request.post('/api/users', { data: { name: 'Test User', email, password }, }); expect(response.ok()).toBeTruthy(); const user = await response.json(); await use({ id: user.id, email, password }); await request.delete(`/api/users/${user.id}`); }, seedProject: async ({ request, seedUser }, use) => { const response = await request.post('/api/projects', { data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id }, }); expect(response.ok()).toBeTruthy(); const project = await response.json(); await use({ id: project.id, name: project.name }); await request.delete(`/api/projects/${project.id}`); }, }); module.exports = { test, expect }; ``` ```javascript // tests/e2e/project-dashboard.spec.js const { test, expect } = require('../../fixtures/seed-fixtures'); test('user sees their project on dashboard', async ({ page, seedUser, seedProject }) => { await page.goto('/login'); await page.getByLabel('Email').fill(seedUser.email); await page.getByLabel('Password').fill(seedUser.password); await page.getByRole('button', { name: 'Sign in' }).click(); await page.waitForURL('/dashboard'); await expect(page.getByRole('heading', { name: seedProject.name })).toBeVisible(); }); ``` ### Schema Validation **Use when**: Verifying that API responses match a contract — field types, required fields, value constraints. Catches backend regressions early. **Avoid when**: You only need to check one or two specific fields. Use `toMatchObject` instead. #### Option A: Zod (recommended for TypeScript projects) **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import { z } from 'zod'; // Define schemas once, reuse across tests const UserSchema = z.object({ id: z.number().positive(), name: z.string().min(1), email: z.string().email(), role: z.enum(['admin', 'editor', 'viewer']), createdAt: z.string().datetime(), profile: z.object({ avatar: z.string().url().nullable(), bio: z.string().max(500), }), }); const PaginatedUsersSchema = z.object({ users: z.array(UserSchema), pagination: z.object({ page: z.number().int().positive(), limit: z.number().int().positive(), total: z.number().int().nonnegative(), }), }); test('GET /api/users matches schema', async ({ request }) => { const response = await request.get('/api/users'); expect(response.ok()).toBeTruthy(); const body = await response.json(); const result = PaginatedUsersSchema.safeParse(body); if (!result.success) { // Detailed error output showing exactly which fields failed throw new Error( `Schema validation failed:\n${result.error.issues .map((i) => ` ${i.path.join('.')}: ${i.message}`) .join('\n')}` ); } }); test('POST /api/users returns valid user', async ({ request }) => { const response = await request.post('/api/users', { data: { name: 'Schema Test', email: `schema-${Date.now()}@example.com` }, }); const body = await response.json(); // Throws with detailed path info if validation fails UserSchema.parse(body); }); ``` #### Option B: Manual type checks (no dependencies) **TypeScript** ```typescript import { test, expect } from '@playwright/test'; function assertUserShape(user: unknown): void { expect(user).toBeDefined(); expect(user).toMatchObject({ id: expect.any(Number), name: expect.any(String), email: expect.any(String), role: expect.any(String), createdAt: expect.any(String), }); const u = user as Record; // Value constraints expect(['admin', 'editor', 'viewer']).toContain(u.role); expect(typeof u.email === 'string' && u.email.includes('@')).toBe(true); expect(new Date(u.createdAt as string).toString()).not.toBe('Invalid Date'); } test('response matches expected shape', async ({ request }) => { const response = await request.get('/api/users/1'); expect(response.ok()).toBeTruthy(); const body = await response.json(); assertUserShape(body); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); function assertUserShape(user) { expect(user).toBeDefined(); expect(user).toMatchObject({ id: expect.any(Number), name: expect.any(String), email: expect.any(String), role: expect.any(String), createdAt: expect.any(String), }); expect(['admin', 'editor', 'viewer']).toContain(user.role); expect(user.email).toContain('@'); expect(new Date(user.createdAt).toString()).not.toBe('Invalid Date'); } test('response matches expected shape', async ({ request }) => { const response = await request.get('/api/users/1'); expect(response.ok()).toBeTruthy(); const body = await response.json(); assertUserShape(body); }); ``` ### Error Response Testing **Use when**: Every API has error paths. Test them. A missing 401 test today is a security hole tomorrow. **Avoid when**: Never skip error testing. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test.describe('Error responses', () => { test('400 — validation error with details', async ({ request }) => { const response = await request.post('/api/users', { data: { name: '', email: 'not-an-email' }, // invalid }); expect(response.status()).toBe(400); const body = await response.json(); expect(body).toMatchObject({ error: 'Validation Error', details: expect.any(Array), }); // Check individual field errors expect(body.details).toEqual( expect.arrayContaining([ expect.objectContaining({ field: 'name', message: expect.any(String) }), expect.objectContaining({ field: 'email', message: expect.any(String) }), ]) ); }); test('401 — missing authentication', async ({ request }) => { // Create a fresh context with NO auth headers const response = await request.get('/api/protected/resource', { headers: { 'Authorization': '' }, // explicitly clear }); expect(response.status()).toBe(401); const body = await response.json(); expect(body.error).toMatch(/unauthorized|unauthenticated/i); }); test('403 — insufficient permissions', async ({ request }) => { // Assuming `request` is authenticated as a viewer const response = await request.delete('/api/admin/users/1'); expect(response.status()).toBe(403); const body = await response.json(); expect(body.error).toMatch(/forbidden|insufficient permissions/i); }); test('404 — resource not found', async ({ request }) => { const response = await request.get('/api/users/999999'); expect(response.status()).toBe(404); const body = await response.json(); expect(body).toMatchObject({ error: expect.stringMatching(/not found/i), }); }); test('409 — conflict on duplicate resource', async ({ request }) => { const email = `conflict-${Date.now()}@example.com`; await request.post('/api/users', { data: { name: 'First', email }, }); const response = await request.post('/api/users', { data: { name: 'Duplicate', email }, }); expect(response.status()).toBe(409); }); test('422 — unprocessable entity', async ({ request }) => { const response = await request.post('/api/orders', { data: { items: [] }, // empty cart }); expect(response.status()).toBe(422); const body = await response.json(); expect(body.error).toContain('at least one item'); }); test('429 — rate limiting', async ({ request }) => { // Send rapid requests to trigger rate limit const responses = await Promise.all( Array.from({ length: 50 }, () => request.get('/api/search', { params: { q: 'test' } }) ) ); const rateLimited = responses.filter((r) => r.status() === 429); expect(rateLimited.length).toBeGreaterThan(0); // Verify rate limit headers const limited = rateLimited[0]; expect(limited.headers()['retry-after']).toBeDefined(); }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test.describe('Error responses', () => { test('400 — validation error with details', async ({ request }) => { const response = await request.post('/api/users', { data: { name: '', email: 'not-an-email' }, }); expect(response.status()).toBe(400); const body = await response.json(); expect(body).toMatchObject({ error: 'Validation Error', details: expect.any(Array), }); expect(body.details).toEqual( expect.arrayContaining([ expect.objectContaining({ field: 'name', message: expect.any(String) }), expect.objectContaining({ field: 'email', message: expect.any(String) }), ]) ); }); test('401 — missing authentication', async ({ request }) => { const response = await request.get('/api/protected/resource', { headers: { 'Authorization': '' }, }); expect(response.status()).toBe(401); }); test('404 — resource not found', async ({ request }) => { const response = await request.get('/api/users/999999'); expect(response.status()).toBe(404); }); test('409 — conflict on duplicate resource', async ({ request }) => { const email = `conflict-${Date.now()}@example.com`; await request.post('/api/users', { data: { name: 'First', email } }); const response = await request.post('/api/users', { data: { name: 'Duplicate', email }, }); expect(response.status()).toBe(409); }); }); ``` ### File Upload via API **Use when**: Testing file upload endpoints with multipart form data — document uploads, image processing, CSV imports. **Avoid when**: You need to test the browser file picker dialog. Use `page.setInputFiles()` in an E2E test instead. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import path from 'path'; import fs from 'fs'; test('upload a file via multipart form data', async ({ request }) => { const filePath = path.resolve('tests/fixtures/test-document.pdf'); const response = await request.post('/api/documents/upload', { multipart: { file: { name: 'test-document.pdf', mimeType: 'application/pdf', buffer: fs.readFileSync(filePath), }, description: 'Quarterly report', category: 'reports', }, }); expect(response.status()).toBe(201); const body = await response.json(); expect(body).toMatchObject({ id: expect.any(String), filename: 'test-document.pdf', mimeType: 'application/pdf', size: expect.any(Number), url: expect.stringMatching(/^https:\/\//), }); }); test('upload an image with metadata', async ({ request }) => { const imagePath = path.resolve('tests/fixtures/avatar.png'); const response = await request.post('/api/users/42/avatar', { multipart: { image: { name: 'avatar.png', mimeType: 'image/png', buffer: fs.readFileSync(imagePath), }, crop: JSON.stringify({ x: 0, y: 0, width: 200, height: 200 }), }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.avatarUrl).toMatch(/\.png$/); }); test('upload multiple files', async ({ request }) => { const files = ['report-q1.csv', 'report-q2.csv'].map((name) => ({ name, mimeType: 'text/csv', buffer: fs.readFileSync(path.resolve(`tests/fixtures/${name}`)), })); // Send sequential uploads when the API does not support batch const results = []; for (const file of files) { const response = await request.post('/api/imports/csv', { multipart: { file }, }); expect(response.ok()).toBeTruthy(); results.push(await response.json()); } expect(results).toHaveLength(2); }); test('rejects oversized files', async ({ request }) => { // Create a buffer that exceeds the server limit const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB const response = await request.post('/api/documents/upload', { multipart: { file: { name: 'large-file.bin', mimeType: 'application/octet-stream', buffer: largeBuffer, }, }, }); expect(response.status()).toBe(413); // Payload Too Large }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs'); test('upload a file via multipart form data', async ({ request }) => { const filePath = path.resolve('tests/fixtures/test-document.pdf'); const response = await request.post('/api/documents/upload', { multipart: { file: { name: 'test-document.pdf', mimeType: 'application/pdf', buffer: fs.readFileSync(filePath), }, description: 'Quarterly report', category: 'reports', }, }); expect(response.status()).toBe(201); const body = await response.json(); expect(body).toMatchObject({ id: expect.any(String), filename: 'test-document.pdf', mimeType: 'application/pdf', size: expect.any(Number), }); }); test('rejects oversized files', async ({ request }) => { const largeBuffer = Buffer.alloc(11 * 1024 * 1024); const response = await request.post('/api/documents/upload', { multipart: { file: { name: 'large-file.bin', mimeType: 'application/octet-stream', buffer: largeBuffer, }, }, }); expect(response.status()).toBe(413); }); ``` ### Chained API Calls **Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions. This verifies the API's behavior as an integrated system, not just isolated endpoints. **Avoid when**: You can test each endpoint in isolation and the interactions are trivial. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('complete order workflow', async ({ request }) => { // Step 1: Create a product const productResp = await request.post('/api/products', { data: { name: 'Widget', price: 29.99, stock: 100 }, }); expect(productResp.status()).toBe(201); const product = await productResp.json(); // Step 2: Create a cart const cartResp = await request.post('/api/carts', { data: { items: [{ productId: product.id, quantity: 2 }] }, }); expect(cartResp.status()).toBe(201); const cart = await cartResp.json(); expect(cart.total).toBe(59.98); // Step 3: Checkout — create an order from the cart const orderResp = await request.post('/api/orders', { data: { cartId: cart.id, shippingAddress: { street: '123 Test St', city: 'Testville', zip: '12345', }, }, }); expect(orderResp.status()).toBe(201); const order = await orderResp.json(); expect(order.status).toBe('pending'); expect(order.items).toHaveLength(1); expect(order.total).toBe(59.98); // Step 4: Verify order appears in user's order list const ordersResp = await request.get('/api/orders'); const orders = await ordersResp.json(); expect(orders.items.map((o: any) => o.id)).toContain(order.id); // Step 5: Verify product stock decreased const updatedProduct = await (await request.get(`/api/products/${product.id}`)).json(); expect(updatedProduct.stock).toBe(98); // 100 - 2 // Cleanup await request.delete(`/api/orders/${order.id}`); await request.delete(`/api/products/${product.id}`); }); test('state machine transitions — publish workflow', async ({ request }) => { // Create a draft post const createResp = await request.post('/api/posts', { data: { title: 'Draft Post', body: 'Content here.' }, }); const post = await createResp.json(); expect(post.status).toBe('draft'); // Submit for review const reviewResp = await request.patch(`/api/posts/${post.id}/status`, { data: { status: 'in_review' }, }); expect(reviewResp.ok()).toBeTruthy(); expect((await reviewResp.json()).status).toBe('in_review'); // Approve (requires admin — use appropriate fixture in real tests) const approveResp = await request.patch(`/api/posts/${post.id}/status`, { data: { status: 'published' }, }); expect(approveResp.ok()).toBeTruthy(); expect((await approveResp.json()).status).toBe('published'); // Verify: cannot go back to draft from published const revertResp = await request.patch(`/api/posts/${post.id}/status`, { data: { status: 'draft' }, }); expect(revertResp.status()).toBe(422); // Cleanup await request.delete(`/api/posts/${post.id}`); }); test('API + E2E hybrid — seed via API, verify in browser', async ({ request, page }) => { // Seed test data via API const resp = await request.post('/api/products', { data: { name: `Hybrid Test Product ${Date.now()}`, price: 42.00, published: true, }, }); const product = await resp.json(); // Verify via browser await page.goto('/products'); await expect( page.getByRole('heading', { name: product.name }) ).toBeVisible(); await expect(page.getByText('$42.00')).toBeVisible(); // Cleanup via API await request.delete(`/api/products/${product.id}`); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('complete order workflow', async ({ request }) => { const productResp = await request.post('/api/products', { data: { name: 'Widget', price: 29.99, stock: 100 }, }); expect(productResp.status()).toBe(201); const product = await productResp.json(); const cartResp = await request.post('/api/carts', { data: { items: [{ productId: product.id, quantity: 2 }] }, }); expect(cartResp.status()).toBe(201); const cart = await cartResp.json(); expect(cart.total).toBe(59.98); const orderResp = await request.post('/api/orders', { data: { cartId: cart.id, shippingAddress: { street: '123 Test St', city: 'Testville', zip: '12345', }, }, }); expect(orderResp.status()).toBe(201); const order = await orderResp.json(); expect(order.status).toBe('pending'); const ordersResp = await request.get('/api/orders'); const orders = await ordersResp.json(); expect(orders.items.map((o) => o.id)).toContain(order.id); await request.delete(`/api/orders/${order.id}`); await request.delete(`/api/products/${product.id}`); }); test('API + E2E hybrid — seed via API, verify in browser', async ({ request, page }) => { const resp = await request.post('/api/products', { data: { name: `Hybrid Test Product ${Date.now()}`, price: 42.00, published: true, }, }); const product = await resp.json(); await page.goto('/products'); await expect( page.getByRole('heading', { name: product.name }) ).toBeVisible(); await request.delete(`/api/products/${product.id}`); }); ``` ## Decision Guide | Scenario | Use API Tests | Use E2E Tests | Why | |---|---|---|---| | Validate response status/body/headers | Yes | No | No browser needed; 10-100x faster | | Test business logic (calculations, rules) | Yes | No | API tests isolate backend logic from UI | | Verify form submission creates correct data | Seed via API, submit via UI | Yes | UI test validates the form; API check confirms persistence | | Test error messages shown to user | No | Yes | Error rendering is a UI concern | | Validate pagination, filtering, sorting | Yes | Maybe both | API test for correctness; E2E test only if the UI logic is complex | | Seed test data for E2E tests | Yes (fixture) | No | API seeding is fast and reliable | | Test auth flows (login/logout/RBAC) | Yes for token/session logic | Yes for UI flow | Both matter: API protects resources, UI guides users | | Verify file upload processing | Yes | Only if testing file picker UI | API test validates backend processing | | Contract/schema regression testing | Yes | No | Schema tests run in milliseconds | | Test third-party webhook handling | Yes | No | Webhooks are API-to-API; no UI involved | | Verify redirect behavior after action | No | Yes | Redirects are browser/navigation concerns | | Test real-time updates (WebSocket + API trigger) | API triggers | E2E verifies | Seed via API, observe in browser | ## Anti-Patterns | Don't Do This | Problem | Do This Instead | |---|---|---| | Use E2E tests to validate pure API responses | Slow, flaky, launches a browser for no reason | Use `request` fixture — no browser, direct HTTP | | Ignore `response.status()` | A 500 with a fallback body can pass all body assertions | Always assert status first: `expect(response.status()).toBe(200)` | | Skip response header checks | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs | Assert critical headers: `expect(response.headers()['content-type']).toContain('application/json')` | | Only test the happy path | Real users trigger 400, 401, 403, 404, 409, 422 — every one needs a test | Dedicate a `describe` block to error responses | | Hardcode IDs in API tests | Tests break when database is reset or IDs are reassigned | Create resources in the test, use returned IDs | | Share mutable state between tests | Tests that depend on execution order are flaky and cannot run in parallel | Each test creates and cleans up its own data | | Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON | Use `await response.json()` | | Forget cleanup after creating resources | Test pollution: subsequent tests may see stale data or hit unique constraints | Use fixtures with teardown or explicit `delete` calls | | Test GraphQL by checking only `response.ok()` | GraphQL returns 200 even on errors — `errors` array is the real signal | Always check both `data` and `errors` in the response body | | Use `page.request` when you don't need a page | `page.request` shares cookies with the browser context, which may cause auth confusion | Use the standalone `request` fixture for pure API tests | ## Troubleshooting ### "Request failed: connect ECONNREFUSED 127.0.0.1:3000" **Cause**: The API server is not running, or `baseURL` points to the wrong host/port. **Fix**: - Verify the server is running before tests: use `webServer` in `playwright.config.ts` to start it automatically. - Check `baseURL` in your config matches the actual server address. ```typescript // playwright.config.ts export default defineConfig({ webServer: { command: 'npm run start:api', url: 'http://localhost:3000/api/health', reuseExistingServer: !process.env.CI, }, use: { baseURL: 'http://localhost:3000', }, }); ``` ### "response.json() failed — body is not valid JSON" **Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON. **Fix**: - Check `response.status()` first — a 500 or 302 often returns HTML. - Log `await response.text()` to see the actual body. - Verify the `Accept: application/json` header is set. ```typescript const response = await request.get('/api/endpoint'); if (!response.ok()) { console.error(`Status: ${response.status()}, Body: ${await response.text()}`); } const body = await response.json(); // now you know what failed ``` ### "401 Unauthorized" when using `request` fixture **Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically. It starts with a clean slate. **Fix**: - Set `extraHTTPHeaders` in your config or create a custom authenticated fixture. - If you need cookies from a browser login, use `page.request` (which shares the browser context's cookies) instead of the standalone `request` fixture. ```typescript // Option A: config-level headers export default defineConfig({ use: { extraHTTPHeaders: { 'Authorization': `Bearer ${process.env.API_TOKEN}`, }, }, }); // Option B: per-request headers const response = await request.get('/api/resource', { headers: { 'Authorization': `Bearer ${token}` }, }); // Option C: use page.request to inherit browser cookies test('API call with browser auth', async ({ page }) => { await page.goto('/login'); // ... login via UI ... const response = await page.request.get('/api/profile'); expect(response.ok()).toBeTruthy(); }); ``` ### GraphQL returns 200 but data is null **Cause**: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the `errors` array. **Fix**: Always destructure and check both `data` and `errors`. ```typescript const { data, errors } = await response.json(); if (errors) { console.error('GraphQL errors:', JSON.stringify(errors, null, 2)); } expect(errors).toBeUndefined(); expect(data.user).toBeDefined(); ``` ### Tests pass locally but fail in CI **Cause**: Different environments, database state, or missing environment variables. **Fix**: - Use `process.env` for secrets and base URLs — never hardcode them. - Run database seeds or migrations in `globalSetup`. - Use unique identifiers (timestamps, UUIDs) for test data to avoid collisions in parallel runs. - Check that the CI `baseURL` matches the deployed or containerized service. ## Related - [core/configuration.md](configuration.md) — `baseURL`, `extraHTTPHeaders`, and `webServer` config - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) — custom fixture patterns for reusable API clients - [core/authentication.md](authentication.md) — auth patterns including token-based API auth - [core/network-mocking.md](network-mocking.md) — mocking API responses in E2E tests (opposite of this guide) - [core/test-architecture.md](test-architecture.md) — when to use API tests vs E2E vs component tests - [core/when-to-mock.md](when-to-mock.md) — when to hit real APIs vs mock them