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 for
baseURLsetup, core/fixtures-and-hooks.md for custom fixture patterns.
Quick Reference
// 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
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")
})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
// 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",
},
},
],
})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
// 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)
})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
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("@"),
})
}
})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
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")
})
})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
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()
})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
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
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)
})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
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)
})
})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
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)
})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
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.0,
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.0,
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
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",
},
})"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/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 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
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()
})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.
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.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.
Related
- 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