# 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.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** ```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 `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