Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
testing-patterns/api-testing.md
1# API Testing23## Table of Contents451. [Patterns](#patterns)62. [Decision Guide](#decision-guide)73. [Anti-Patterns](#anti-patterns)84. [Troubleshooting](#troubleshooting)910> **When to use**: Testing REST APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead.11> **See also**: [graphql-testing.md](graphql-testing.md) for GraphQL-specific patterns.1213## Patterns1415### Request Fixtures for Authenticated Clients1617**Use when**: Multiple tests need an authenticated API client with shared configuration.18**Avoid when**: A single test makes one-off API calls — use the built-in `request` fixture directly.1920```typescript21// fixtures/api-fixtures.ts22import { test as base, expect, APIRequestContext } from "@playwright/test";2324type ApiFixtures = {25authApi: APIRequestContext;26adminApi: APIRequestContext;27};2829export const test = base.extend<ApiFixtures>({30authApi: async ({ playwright }, use) => {31const ctx = await playwright.request.newContext({32baseURL: "https://api.myapp.io",33extraHTTPHeaders: {34Authorization: `Bearer ${process.env.API_TOKEN}`,35Accept: "application/json",36},37});38await use(ctx);39await ctx.dispose();40},4142adminApi: async ({ playwright }, use) => {43const loginCtx = await playwright.request.newContext({44baseURL: "https://api.myapp.io",45});46const loginResp = await loginCtx.post("/auth/login", {47data: {48email: process.env.ADMIN_EMAIL,49password: process.env.ADMIN_PASSWORD,50},51});52expect(loginResp.ok()).toBeTruthy();53const { token } = await loginResp.json();54await loginCtx.dispose();5556const ctx = await playwright.request.newContext({57baseURL: "https://api.myapp.io",58extraHTTPHeaders: {59Authorization: `Bearer ${token}`,60Accept: "application/json",61},62});63await use(ctx);64await ctx.dispose();65},66});6768export { expect };69```7071```typescript72// tests/api/admin.spec.ts73import { test, expect } from "../../fixtures/api-fixtures";7475test("admin retrieves all accounts", async ({ adminApi }) => {76const resp = await adminApi.get("/admin/accounts");77expect(resp.status()).toBe(200);78const body = await resp.json();79expect(body.accounts.length).toBeGreaterThan(0);80});81```8283### CRUD Operations8485**Use when**: Making HTTP requests — GET, POST, PUT, PATCH, DELETE with headers, query params, and bodies.86**Avoid when**: You need to test browser-rendered responses (redirects, cookies with `HttpOnly`).8788```typescript89import { test, expect } from "@playwright/test";9091test("full CRUD cycle", async ({ request }) => {92// GET with query params93const listResp = await request.get("/api/items", {94params: { page: 1, limit: 10, category: "tools" },95});96expect(listResp.ok()).toBeTruthy();9798// POST with JSON body99const createResp = await request.post("/api/items", {100data: {101title: "Hammer",102price: 19.99,103category: "tools",104},105});106expect(createResp.status()).toBe(201);107const created = await createResp.json();108109// PUT — full replacement110const putResp = await request.put(`/api/items/${created.id}`, {111data: {112title: "Claw Hammer",113price: 24.99,114category: "tools",115},116});117expect(putResp.ok()).toBeTruthy();118119// PATCH — partial update120const patchResp = await request.patch(`/api/items/${created.id}`, {121data: { price: 22.5 },122});123expect(patchResp.ok()).toBeTruthy();124const patched = await patchResp.json();125expect(patched.price).toBe(22.5);126127// DELETE128const delResp = await request.delete(`/api/items/${created.id}`);129expect(delResp.status()).toBe(204);130131// Verify deletion132const getDeleted = await request.get(`/api/items/${created.id}`);133expect(getDeleted.status()).toBe(404);134});135136test("form-urlencoded body", async ({ request }) => {137const resp = await request.post("/oauth/token", {138form: {139grant_type: "client_credentials",140client_id: "my-client",141client_secret: "secret-value",142},143});144expect(resp.ok()).toBeTruthy();145const token = await resp.json();146expect(token).toHaveProperty("access_token");147});148```149150### Dedicated API Project Configuration151152**Use when**: Writing dedicated API test suites that do not need a browser.153154```typescript155// playwright.config.ts156import { defineConfig } from "@playwright/test";157158export default defineConfig({159projects: [160{161name: "api",162testDir: "./tests/api",163use: {164baseURL: "https://api.myapp.io",165extraHTTPHeaders: { Accept: "application/json" },166},167},168{169name: "e2e",170testDir: "./tests/e2e",171use: {172baseURL: "https://myapp.io",173browserName: "chromium",174},175},176],177});178```179180### Response Assertions181182**Use when**: Validating response status, headers, and body structure.183**Avoid when**: Never skip these — every API test should assert on status and body.184185```typescript186import { test, expect } from "@playwright/test";187188test("comprehensive response validation", async ({ request }) => {189const resp = await request.get("/api/items/101");190191// Status code — always check first192expect(resp.status()).toBe(200);193expect(resp.ok()).toBeTruthy();194195// Headers196expect(resp.headers()["content-type"]).toContain("application/json");197expect(resp.headers()["cache-control"]).toMatch(/max-age=\d+/);198199const item = await resp.json();200201// Exact match on known fields202expect(item.id).toBe(101);203expect(item.title).toBe("Widget");204205// Partial match — ignore fields you don't care about206expect(item).toMatchObject({207id: 101,208title: "Widget",209status: expect.stringMatching(/^(active|inactive|archived)$/),210});211212// Type checks213expect(item).toMatchObject({214id: expect.any(Number),215title: expect.any(String),216createdAt: expect.any(String),217tags: expect.any(Array),218});219220// Array content221expect(item.tags).toEqual(expect.arrayContaining(["featured"]));222expect(item.tags).not.toContain("deprecated");223224// Nested object225expect(item.metadata).toMatchObject({226views: expect.any(Number),227rating: expect.any(Number),228});229230// Date format231expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt);232});233234test("list response structure", async ({ request }) => {235const resp = await request.get("/api/items");236const body = await resp.json();237238expect(body.items).toHaveLength(10);239240for (const item of body.items) {241expect(item).toMatchObject({242id: expect.any(Number),243title: expect.any(String),244price: expect.any(Number),245});246}247248expect(body.pagination).toEqual({249page: 1,250limit: 10,251total: expect.any(Number),252totalPages: expect.any(Number),253});254});255```256257### API Data Seeding258259**Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup.260**Avoid when**: The test specifically validates the creation flow through the UI.261262```typescript263import { test as base, expect } from "@playwright/test";264265type SeedFixtures = {266seedAccount: { id: number; email: string; password: string };267seedWorkspace: { id: number; name: string };268};269270export const test = base.extend<SeedFixtures>({271seedAccount: async ({ request }, use) => {272const email = `account-${Date.now()}@test.io`;273const password = "SecurePass123!";274275const resp = await request.post("/api/accounts", {276data: { name: "Test Account", email, password },277});278expect(resp.ok()).toBeTruthy();279const account = await resp.json();280281await use({ id: account.id, email, password });282283// Cleanup284await request.delete(`/api/accounts/${account.id}`);285},286287seedWorkspace: async ({ request, seedAccount }, use) => {288const resp = await request.post("/api/workspaces", {289data: { name: `Workspace ${Date.now()}`, ownerId: seedAccount.id },290});291expect(resp.ok()).toBeTruthy();292const workspace = await resp.json();293294await use({ id: workspace.id, name: workspace.name });295296await request.delete(`/api/workspaces/${workspace.id}`);297},298});299300export { expect };301```302303```typescript304// tests/e2e/workspace-dashboard.spec.ts305import { test, expect } from "../../fixtures/seed-fixtures";306307test("user sees workspace on dashboard", async ({308page,309seedAccount,310seedWorkspace,311}) => {312await page.goto("/login");313await page.getByLabel("Email").fill(seedAccount.email);314await page.getByLabel("Password").fill(seedAccount.password);315await page.getByRole("button", { name: "Sign in" }).click();316317await page.waitForURL("/dashboard");318await expect(319page.getByRole("heading", { name: seedWorkspace.name })320).toBeVisible();321});322```323324### Error Response Testing325326**Use when**: Every API has error paths — test them. A missing 401 test today is a security hole tomorrow.327328```typescript329import { test, expect } from "@playwright/test";330331test.describe("Error responses", () => {332test("400 — validation error with details", async ({ request }) => {333const resp = await request.post("/api/items", {334data: { title: "", price: -5 },335});336expect(resp.status()).toBe(400);337338const body = await resp.json();339expect(body).toMatchObject({340error: "Validation Error",341details: expect.any(Array),342});343expect(body.details).toEqual(344expect.arrayContaining([345expect.objectContaining({346field: "title",347message: expect.any(String),348}),349expect.objectContaining({350field: "price",351message: expect.any(String),352}),353])354);355});356357test("401 — missing authentication", async ({ request }) => {358const resp = await request.get("/api/protected/resource", {359headers: { Authorization: "" },360});361expect(resp.status()).toBe(401);362const body = await resp.json();363expect(body.error).toMatch(/unauthorized|unauthenticated/i);364});365366test("403 — insufficient permissions", async ({ request }) => {367const resp = await request.delete("/api/admin/items/1");368expect(resp.status()).toBe(403);369const body = await resp.json();370expect(body.error).toMatch(/forbidden|insufficient permissions/i);371});372373test("404 — resource not found", async ({ request }) => {374const resp = await request.get("/api/items/999999");375expect(resp.status()).toBe(404);376const body = await resp.json();377expect(body).toMatchObject({ error: expect.stringMatching(/not found/i) });378});379380test("409 — conflict on duplicate", async ({ request }) => {381const sku = `SKU-${Date.now()}`;382await request.post("/api/items", { data: { title: "First", sku } });383384const resp = await request.post("/api/items", {385data: { title: "Duplicate", sku },386});387expect(resp.status()).toBe(409);388});389390test("422 — unprocessable entity", async ({ request }) => {391const resp = await request.post("/api/orders", {392data: { items: [] },393});394expect(resp.status()).toBe(422);395const body = await resp.json();396expect(body.error).toContain("at least one item");397});398399test("429 — rate limiting", async ({ request }) => {400const responses = await Promise.all(401Array.from({ length: 50 }, () =>402request.get("/api/search", { params: { q: "test" } })403)404);405const rateLimited = responses.filter((r) => r.status() === 429);406expect(rateLimited.length).toBeGreaterThan(0);407expect(rateLimited[0].headers()["retry-after"]).toBeDefined();408});409});410```411412### File Upload via API413414**Use when**: Testing file upload endpoints with multipart form data.415**Avoid when**: You need to test the browser file picker dialog — use `page.setInputFiles()` instead.416417```typescript418import { test, expect } from "@playwright/test";419import path from "path";420import fs from "fs";421422test("upload file via multipart", async ({ request }) => {423const filePath = path.resolve("tests/fixtures/report.pdf");424425const resp = await request.post("/api/documents/upload", {426multipart: {427file: {428name: "report.pdf",429mimeType: "application/pdf",430buffer: fs.readFileSync(filePath),431},432description: "Monthly report",433category: "reports",434},435});436437expect(resp.status()).toBe(201);438const body = await resp.json();439expect(body).toMatchObject({440id: expect.any(String),441filename: "report.pdf",442mimeType: "application/pdf",443size: expect.any(Number),444url: expect.stringMatching(/^https:\/\//),445});446});447448test("rejects oversized files", async ({ request }) => {449const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB450451const resp = await request.post("/api/documents/upload", {452multipart: {453file: {454name: "large-file.bin",455mimeType: "application/octet-stream",456buffer: largeBuffer,457},458},459});460461expect(resp.status()).toBe(413);462});463```464465### Chained API Calls466467**Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions.468**Avoid when**: You can test each endpoint in isolation and the interactions are trivial.469470```typescript471import { test, expect } from "@playwright/test";472473test("complete order workflow", async ({ request }) => {474// Step 1: Create a product475const productResp = await request.post("/api/products", {476data: { name: "Gadget", price: 49.99, stock: 50 },477});478expect(productResp.status()).toBe(201);479const product = await productResp.json();480481// Step 2: Create a cart482const cartResp = await request.post("/api/carts", {483data: { items: [{ productId: product.id, quantity: 3 }] },484});485expect(cartResp.status()).toBe(201);486const cart = await cartResp.json();487expect(cart.total).toBe(149.97);488489// Step 3: Checkout490const orderResp = await request.post("/api/orders", {491data: {492cartId: cart.id,493shippingAddress: {494street: "456 Main Ave",495city: "Metropolis",496zip: "54321",497},498},499});500expect(orderResp.status()).toBe(201);501const order = await orderResp.json();502expect(order.status).toBe("pending");503expect(order.items).toHaveLength(1);504505// Step 4: Verify order in list506const ordersResp = await request.get("/api/orders");507const orders = await ordersResp.json();508expect(orders.items.map((o: any) => o.id)).toContain(order.id);509510// Step 5: Verify stock decreased511const updatedProduct = await (512await request.get(`/api/products/${product.id}`)513).json();514expect(updatedProduct.stock).toBe(47);515516// Cleanup517await request.delete(`/api/orders/${order.id}`);518await request.delete(`/api/products/${product.id}`);519});520521test("state machine transitions — publish workflow", async ({ request }) => {522const createResp = await request.post("/api/articles", {523data: { title: "Draft Article", body: "Content here." },524});525const article = await createResp.json();526expect(article.status).toBe("draft");527528// Submit for review529const reviewResp = await request.patch(`/api/articles/${article.id}/status`, {530data: { status: "in_review" },531});532expect(reviewResp.ok()).toBeTruthy();533expect((await reviewResp.json()).status).toBe("in_review");534535// Approve536const approveResp = await request.patch(537`/api/articles/${article.id}/status`,538{539data: { status: "published" },540}541);542expect(approveResp.ok()).toBeTruthy();543expect((await approveResp.json()).status).toBe("published");544545// Cannot revert to draft from published546const revertResp = await request.patch(`/api/articles/${article.id}/status`, {547data: { status: "draft" },548});549expect(revertResp.status()).toBe(422);550551await request.delete(`/api/articles/${article.id}`);552});553554test("API + E2E hybrid — seed via API, verify in browser", async ({555request,556page,557}) => {558const resp = await request.post("/api/products", {559data: {560name: `Hybrid Product ${Date.now()}`,561price: 35.0,562published: true,563},564});565const product = await resp.json();566567await page.goto("/products");568await expect(page.getByRole("heading", { name: product.name })).toBeVisible();569await expect(page.getByText("$35.00")).toBeVisible();570571await request.delete(`/api/products/${product.id}`);572});573```574575### Schema Validation with Zod576577**Use when**: Verifying API responses match a contract — field types, required fields, value constraints.578**Avoid when**: You only need to check one or two specific fields — use `toMatchObject` instead.579580```typescript581import { test, expect } from "@playwright/test";582import { z } from "zod";583584const ItemSchema = z.object({585id: z.number().positive(),586title: z.string().min(1),587price: z.number().nonnegative(),588status: z.enum(["active", "inactive", "archived"]),589createdAt: z.string().datetime(),590metadata: z.object({591views: z.number().int().nonnegative(),592rating: z.number().min(0).max(5).nullable(),593}),594});595596const PaginatedItemsSchema = z.object({597items: z.array(ItemSchema),598pagination: z.object({599page: z.number().int().positive(),600limit: z.number().int().positive(),601total: z.number().int().nonnegative(),602}),603});604605test("GET /api/items matches schema", async ({ request }) => {606const resp = await request.get("/api/items");607expect(resp.ok()).toBeTruthy();608609const body = await resp.json();610const result = PaginatedItemsSchema.safeParse(body);611612if (!result.success) {613throw new Error(614`Schema validation failed:\n${result.error.issues615.map((i) => ` ${i.path.join(".")}: ${i.message}`)616.join("\n")}`617);618}619});620```621622## Decision Guide623624| Scenario | Use API Tests | Use E2E Tests | Why |625| ------------------------------------------------ | --------------------------- | ------------------------------ | ------------------------------------------------------------------ |626| Validate response status/body/headers | Yes | No | No browser needed; 10-100x faster |627| Test business logic (calculations, rules) | Yes | No | API tests isolate backend logic from UI |628| Verify form submission creates correct data | Seed via API, submit via UI | Yes | UI test validates the form; API check confirms persistence |629| Test error messages shown to user | No | Yes | Error rendering is a UI concern |630| Validate pagination, filtering, sorting | Yes | Maybe both | API test for correctness; E2E test only if the UI logic is complex |631| Seed test data for E2E tests | Yes (fixture) | No | API seeding is fast and reliable |632| Test auth flows (login/logout/RBAC) | Yes for token/session logic | Yes for UI flow | Both matter: API protects resources, UI guides users |633| Verify file upload processing | Yes | Only if testing file picker UI | API test validates backend processing |634| Contract/schema regression testing | Yes | No | Schema tests run in milliseconds |635| Test third-party webhook handling | Yes | No | Webhooks are API-to-API; no UI involved |636| Verify redirect behavior after action | No | Yes | Redirects are browser/navigation concerns |637| Test real-time updates (WebSocket + API trigger) | API triggers | E2E verifies | Seed via API, observe in browser |638639## Anti-Patterns640641| Don't Do This | Problem | Do This Instead |642| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |643| Use E2E tests to validate pure API responses | Slow, flaky, launches a browser for no reason | Use `request` fixture — no browser, direct HTTP |644| Ignore `response.status()` | A 500 with a fallback body can pass all body assertions | Always assert status first: `expect(response.status()).toBe(200)` |645| Skip response header checks | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs | Assert critical headers |646| 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 |647| Hardcode IDs in API tests | Tests break when database is reset or IDs are reassigned | Create resources in the test, use returned IDs |648| 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 |649| Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON | Use `await response.json()` |650| 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 |651| 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 |652653## Troubleshooting654655### "Request failed: connect ECONNREFUSED 127.0.0.1:3000"656657**Cause**: The API server is not running, or `baseURL` points to the wrong host/port.658659**Fix**: Verify the server is running before tests. Use `webServer` in config to start it automatically.660661```typescript662// playwright.config.ts663export default defineConfig({664webServer: {665command: "npm run start:api",666url: "http://localhost:3000/api/health",667reuseExistingServer: !process.env.CI,668},669use: { baseURL: "http://localhost:3000" },670});671```672673### "response.json() failed — body is not valid JSON"674675**Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON.676677**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.678679```typescript680const resp = await request.get("/api/endpoint");681if (!resp.ok()) {682console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`);683}684const body = await resp.json();685```686687### "401 Unauthorized" when using `request` fixture688689**Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically.690691**Fix**: Set `extraHTTPHeaders` in config or create a custom authenticated fixture. If you need cookies from a browser login, use `page.request` instead.692693```typescript694// Option A: config-level headers695export default defineConfig({696use: {697extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },698},699});700701// Option B: per-request headers702const resp = await request.get("/api/resource", {703headers: { Authorization: `Bearer ${token}` },704});705706// Option C: use page.request to inherit browser cookies707test("API call with browser auth", async ({ page }) => {708await page.goto("/login");709// ... login via UI ...710const resp = await page.request.get("/api/profile");711expect(resp.ok()).toBeTruthy();712});713```714715### Tests pass locally but fail in CI716717**Cause**: Different environments, database state, or missing environment variables.718719**Fix**: Use `process.env` for secrets and base URLs. Run database seeds or migrations in `globalSetup`. Use unique identifiers (timestamps, UUIDs) for test data. Check that the CI `baseURL` matches the deployed service.720