# 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<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 }
```

```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<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 }
```

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

```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
