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 for
baseURLsetup, core/fixtures-and-hooks.md for custom fixture patterns.
// 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) });
});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
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
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');
});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
// 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
// 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.
// 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',
},
},
],
});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
// fixtures/api-fixtures.ts
import { test as base, expect, APIRequestContext } from '@playwright/test';
type ApiFixtures = {
authenticatedRequest: APIRequestContext;
adminRequest: APIRequestContext;
};
export const test = base.extend<ApiFixtures>({
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 };// 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
// 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 };// 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);
});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
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
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('@'),
});
}
});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
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
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');
});
});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
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<SeedFixtures>({
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 };// 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
// 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 };// 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();
});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.
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);
});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<string, unknown>;
// 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
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);
});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
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
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);
});
});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
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
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);
});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
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
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}`);
});| 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 |
| 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 |
Cause: The API server is not running, or baseURL points to the wrong host/port.
Fix:
- Verify the server is running before tests: use
webServerinplaywright.config.tsto start it automatically. - Check
baseURLin your config matches the actual server address.
// 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',
},
});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/jsonheader is set.
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 failedCause: The built-in request fixture does not carry browser cookies or auth tokens automatically. It starts with a clean slate.
Fix:
- Set
extraHTTPHeadersin 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 standalonerequestfixture.
// 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();
});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.
const { data, errors } = await response.json();
if (errors) {
console.error('GraphQL errors:', JSON.stringify(errors, null, 2));
}
expect(errors).toBeUndefined();
expect(data.user).toBeDefined();Cause: Different environments, database state, or missing environment variables.
Fix:
- Use
process.envfor 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
baseURLmatches the deployed or containerized service.
- core/configuration.md —
baseURL,extraHTTPHeaders, andwebServerconfig - core/fixtures-and-hooks.md — custom fixture patterns for reusable API clients
- core/authentication.md — auth patterns including token-based API auth
- core/network-mocking.md — mocking API responses in E2E tests (opposite of this guide)
- core/test-architecture.md — when to use API tests vs E2E vs component tests
- core/when-to-mock.md — when to hit real APIs vs mock them