Back to Sanity

API Testing

.agents/skills/playwright-best-practices/testing-patterns/api-testing.md

5.24.023.9 KB
Original Source

API Testing

Table of Contents

  1. Patterns
  2. Decision Guide
  3. Anti-Patterns
  4. Troubleshooting

When to use: Testing REST APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead. See also: graphql-testing.md for GraphQL-specific patterns.

Patterns

Request Fixtures for Authenticated Clients

Use when: Multiple tests need an authenticated API client with shared configuration. Avoid when: A single test makes one-off API calls — use the built-in request fixture directly.

typescript
// fixtures/api-fixtures.ts
import {test as base, expect, APIRequestContext} from '@playwright/test'

type ApiFixtures = {
  authApi: APIRequestContext
  adminApi: APIRequestContext
}

export const test = base.extend<ApiFixtures>({
  authApi: async ({playwright}, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: 'https://api.myapp.io',
      extraHTTPHeaders: {
        Authorization: `Bearer ${process.env.API_TOKEN}`,
        Accept: 'application/json',
      },
    })
    await use(ctx)
    await ctx.dispose()
  },

  adminApi: async ({playwright}, use) => {
    const loginCtx = await playwright.request.newContext({
      baseURL: 'https://api.myapp.io',
    })
    const loginResp = await loginCtx.post('/auth/login', {
      data: {
        email: process.env.ADMIN_EMAIL,
        password: process.env.ADMIN_PASSWORD,
      },
    })
    expect(loginResp.ok()).toBeTruthy()
    const {token} = await loginResp.json()
    await loginCtx.dispose()

    const ctx = await playwright.request.newContext({
      baseURL: 'https://api.myapp.io',
      extraHTTPHeaders: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
      },
    })
    await use(ctx)
    await ctx.dispose()
  },
})

export {expect}
typescript
// tests/api/admin.spec.ts
import {test, expect} from '../../fixtures/api-fixtures'

test('admin retrieves all accounts', async ({adminApi}) => {
  const resp = await adminApi.get('/admin/accounts')
  expect(resp.status()).toBe(200)
  const body = await resp.json()
  expect(body.accounts.length).toBeGreaterThan(0)
})

CRUD Operations

Use when: Making HTTP requests — GET, POST, PUT, PATCH, DELETE with headers, query params, and bodies. Avoid when: You need to test browser-rendered responses (redirects, cookies with HttpOnly).

typescript
import {test, expect} from '@playwright/test'

test('full CRUD cycle', async ({request}) => {
  // GET with query params
  const listResp = await request.get('/api/items', {
    params: {page: 1, limit: 10, category: 'tools'},
  })
  expect(listResp.ok()).toBeTruthy()

  // POST with JSON body
  const createResp = await request.post('/api/items', {
    data: {
      title: 'Hammer',
      price: 19.99,
      category: 'tools',
    },
  })
  expect(createResp.status()).toBe(201)
  const created = await createResp.json()

  // PUT — full replacement
  const putResp = await request.put(`/api/items/${created.id}`, {
    data: {
      title: 'Claw Hammer',
      price: 24.99,
      category: 'tools',
    },
  })
  expect(putResp.ok()).toBeTruthy()

  // PATCH — partial update
  const patchResp = await request.patch(`/api/items/${created.id}`, {
    data: {price: 22.5},
  })
  expect(patchResp.ok()).toBeTruthy()
  const patched = await patchResp.json()
  expect(patched.price).toBe(22.5)

  // DELETE
  const delResp = await request.delete(`/api/items/${created.id}`)
  expect(delResp.status()).toBe(204)

  // Verify deletion
  const getDeleted = await request.get(`/api/items/${created.id}`)
  expect(getDeleted.status()).toBe(404)
})

test('form-urlencoded body', async ({request}) => {
  const resp = await request.post('/oauth/token', {
    form: {
      grant_type: 'client_credentials',
      client_id: 'my-client',
      client_secret: 'secret-value',
    },
  })
  expect(resp.ok()).toBeTruthy()
  const token = await resp.json()
  expect(token).toHaveProperty('access_token')
})

Dedicated API Project Configuration

Use when: Writing dedicated API test suites that do not need a browser.

typescript
// playwright.config.ts
import {defineConfig} from '@playwright/test'

export default defineConfig({
  projects: [
    {
      name: 'api',
      testDir: './tests/api',
      use: {
        baseURL: 'https://api.myapp.io',
        extraHTTPHeaders: {Accept: 'application/json'},
      },
    },
    {
      name: 'e2e',
      testDir: './tests/e2e',
      use: {
        baseURL: 'https://myapp.io',
        browserName: 'chromium',
      },
    },
  ],
})

Response Assertions

Use when: Validating response status, headers, and body structure. Avoid when: Never skip these — every API test should assert on status and body.

typescript
import {test, expect} from '@playwright/test'

test('comprehensive response validation', async ({request}) => {
  const resp = await request.get('/api/items/101')

  // Status code — always check first
  expect(resp.status()).toBe(200)
  expect(resp.ok()).toBeTruthy()

  // Headers
  expect(resp.headers()['content-type']).toContain('application/json')
  expect(resp.headers()['cache-control']).toMatch(/max-age=\d+/)

  const item = await resp.json()

  // Exact match on known fields
  expect(item.id).toBe(101)
  expect(item.title).toBe('Widget')

  // Partial match — ignore fields you don't care about
  expect(item).toMatchObject({
    id: 101,
    title: 'Widget',
    status: expect.stringMatching(/^(active|inactive|archived)$/),
  })

  // Type checks
  expect(item).toMatchObject({
    id: expect.any(Number),
    title: expect.any(String),
    createdAt: expect.any(String),
    tags: expect.any(Array),
  })

  // Array content
  expect(item.tags).toEqual(expect.arrayContaining(['featured']))
  expect(item.tags).not.toContain('deprecated')

  // Nested object
  expect(item.metadata).toMatchObject({
    views: expect.any(Number),
    rating: expect.any(Number),
  })

  // Date format
  expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt)
})

test('list response structure', async ({request}) => {
  const resp = await request.get('/api/items')
  const body = await resp.json()

  expect(body.items).toHaveLength(10)

  for (const item of body.items) {
    expect(item).toMatchObject({
      id: expect.any(Number),
      title: expect.any(String),
      price: expect.any(Number),
    })
  }

  expect(body.pagination).toEqual({
    page: 1,
    limit: 10,
    total: expect.any(Number),
    totalPages: expect.any(Number),
  })
})

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.

typescript
import {test as base, expect} from '@playwright/test'

type SeedFixtures = {
  seedAccount: {id: number; email: string; password: string}
  seedWorkspace: {id: number; name: string}
}

export const test = base.extend<SeedFixtures>({
  seedAccount: async ({request}, use) => {
    const email = `account-${Date.now()}@test.io`
    const password = 'SecurePass123!'

    const resp = await request.post('/api/accounts', {
      data: {name: 'Test Account', email, password},
    })
    expect(resp.ok()).toBeTruthy()
    const account = await resp.json()

    await use({id: account.id, email, password})

    // Cleanup
    await request.delete(`/api/accounts/${account.id}`)
  },

  seedWorkspace: async ({request, seedAccount}, use) => {
    const resp = await request.post('/api/workspaces', {
      data: {name: `Workspace ${Date.now()}`, ownerId: seedAccount.id},
    })
    expect(resp.ok()).toBeTruthy()
    const workspace = await resp.json()

    await use({id: workspace.id, name: workspace.name})

    await request.delete(`/api/workspaces/${workspace.id}`)
  },
})

export {expect}
typescript
// tests/e2e/workspace-dashboard.spec.ts
import {test, expect} from '../../fixtures/seed-fixtures'

test('user sees workspace on dashboard', async ({page, seedAccount, seedWorkspace}) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill(seedAccount.email)
  await page.getByLabel('Password').fill(seedAccount.password)
  await page.getByRole('button', {name: 'Sign in'}).click()

  await page.waitForURL('/dashboard')
  await expect(page.getByRole('heading', {name: seedWorkspace.name})).toBeVisible()
})

Error Response Testing

Use when: Every API has error paths — test them. A missing 401 test today is a security hole tomorrow.

typescript
import {test, expect} from '@playwright/test'

test.describe('Error responses', () => {
  test('400 — validation error with details', async ({request}) => {
    const resp = await request.post('/api/items', {
      data: {title: '', price: -5},
    })
    expect(resp.status()).toBe(400)

    const body = await resp.json()
    expect(body).toMatchObject({
      error: 'Validation Error',
      details: expect.any(Array),
    })
    expect(body.details).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          field: 'title',
          message: expect.any(String),
        }),
        expect.objectContaining({
          field: 'price',
          message: expect.any(String),
        }),
      ]),
    )
  })

  test('401 — missing authentication', async ({request}) => {
    const resp = await request.get('/api/protected/resource', {
      headers: {Authorization: ''},
    })
    expect(resp.status()).toBe(401)
    const body = await resp.json()
    expect(body.error).toMatch(/unauthorized|unauthenticated/i)
  })

  test('403 — insufficient permissions', async ({request}) => {
    const resp = await request.delete('/api/admin/items/1')
    expect(resp.status()).toBe(403)
    const body = await resp.json()
    expect(body.error).toMatch(/forbidden|insufficient permissions/i)
  })

  test('404 — resource not found', async ({request}) => {
    const resp = await request.get('/api/items/999999')
    expect(resp.status()).toBe(404)
    const body = await resp.json()
    expect(body).toMatchObject({error: expect.stringMatching(/not found/i)})
  })

  test('409 — conflict on duplicate', async ({request}) => {
    const sku = `SKU-${Date.now()}`
    await request.post('/api/items', {data: {title: 'First', sku}})

    const resp = await request.post('/api/items', {
      data: {title: 'Duplicate', sku},
    })
    expect(resp.status()).toBe(409)
  })

  test('422 — unprocessable entity', async ({request}) => {
    const resp = await request.post('/api/orders', {
      data: {items: []},
    })
    expect(resp.status()).toBe(422)
    const body = await resp.json()
    expect(body.error).toContain('at least one item')
  })

  test('429 — rate limiting', async ({request}) => {
    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)
    expect(rateLimited[0].headers()['retry-after']).toBeDefined()
  })
})

File Upload via API

Use when: Testing file upload endpoints with multipart form data. Avoid when: You need to test the browser file picker dialog — use page.setInputFiles() instead.

typescript
import {test, expect} from '@playwright/test'
import path from 'path'
import fs from 'fs'

test('upload file via multipart', async ({request}) => {
  const filePath = path.resolve('tests/fixtures/report.pdf')

  const resp = await request.post('/api/documents/upload', {
    multipart: {
      file: {
        name: 'report.pdf',
        mimeType: 'application/pdf',
        buffer: fs.readFileSync(filePath),
      },
      description: 'Monthly report',
      category: 'reports',
    },
  })

  expect(resp.status()).toBe(201)
  const body = await resp.json()
  expect(body).toMatchObject({
    id: expect.any(String),
    filename: 'report.pdf',
    mimeType: 'application/pdf',
    size: expect.any(Number),
    url: expect.stringMatching(/^https:\/\//),
  })
})

test('rejects oversized files', async ({request}) => {
  const largeBuffer = Buffer.alloc(11 * 1024 * 1024) // 11MB

  const resp = await request.post('/api/documents/upload', {
    multipart: {
      file: {
        name: 'large-file.bin',
        mimeType: 'application/octet-stream',
        buffer: largeBuffer,
      },
    },
  })

  expect(resp.status()).toBe(413)
})

Chained API Calls

Use when: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions. Avoid when: You can test each endpoint in isolation and the interactions are trivial.

typescript
import {test, expect} from '@playwright/test'

test('complete order workflow', async ({request}) => {
  // Step 1: Create a product
  const productResp = await request.post('/api/products', {
    data: {name: 'Gadget', price: 49.99, stock: 50},
  })
  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: 3}]},
  })
  expect(cartResp.status()).toBe(201)
  const cart = await cartResp.json()
  expect(cart.total).toBe(149.97)

  // Step 3: Checkout
  const orderResp = await request.post('/api/orders', {
    data: {
      cartId: cart.id,
      shippingAddress: {
        street: '456 Main Ave',
        city: 'Metropolis',
        zip: '54321',
      },
    },
  })
  expect(orderResp.status()).toBe(201)
  const order = await orderResp.json()
  expect(order.status).toBe('pending')
  expect(order.items).toHaveLength(1)

  // Step 4: Verify order in 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 stock decreased
  const updatedProduct = await (await request.get(`/api/products/${product.id}`)).json()
  expect(updatedProduct.stock).toBe(47)

  // Cleanup
  await request.delete(`/api/orders/${order.id}`)
  await request.delete(`/api/products/${product.id}`)
})

test('state machine transitions — publish workflow', async ({request}) => {
  const createResp = await request.post('/api/articles', {
    data: {title: 'Draft Article', body: 'Content here.'},
  })
  const article = await createResp.json()
  expect(article.status).toBe('draft')

  // Submit for review
  const reviewResp = await request.patch(`/api/articles/${article.id}/status`, {
    data: {status: 'in_review'},
  })
  expect(reviewResp.ok()).toBeTruthy()
  expect((await reviewResp.json()).status).toBe('in_review')

  // Approve
  const approveResp = await request.patch(`/api/articles/${article.id}/status`, {
    data: {status: 'published'},
  })
  expect(approveResp.ok()).toBeTruthy()
  expect((await approveResp.json()).status).toBe('published')

  // Cannot revert to draft from published
  const revertResp = await request.patch(`/api/articles/${article.id}/status`, {
    data: {status: 'draft'},
  })
  expect(revertResp.status()).toBe(422)

  await request.delete(`/api/articles/${article.id}`)
})

test('API + E2E hybrid — seed via API, verify in browser', async ({request, page}) => {
  const resp = await request.post('/api/products', {
    data: {
      name: `Hybrid Product ${Date.now()}`,
      price: 35.0,
      published: true,
    },
  })
  const product = await resp.json()

  await page.goto('/products')
  await expect(page.getByRole('heading', {name: product.name})).toBeVisible()
  await expect(page.getByText('$35.00')).toBeVisible()

  await request.delete(`/api/products/${product.id}`)
})

Schema Validation with Zod

Use when: Verifying API responses match a contract — field types, required fields, value constraints. Avoid when: You only need to check one or two specific fields — use toMatchObject instead.

typescript
import {test, expect} from '@playwright/test'
import {z} from 'zod'

const ItemSchema = z.object({
  id: z.number().positive(),
  title: z.string().min(1),
  price: z.number().nonnegative(),
  status: z.enum(['active', 'inactive', 'archived']),
  createdAt: z.string().datetime(),
  metadata: z.object({
    views: z.number().int().nonnegative(),
    rating: z.number().min(0).max(5).nullable(),
  }),
})

const PaginatedItemsSchema = z.object({
  items: z.array(ItemSchema),
  pagination: z.object({
    page: z.number().int().positive(),
    limit: z.number().int().positive(),
    total: z.number().int().nonnegative(),
  }),
})

test('GET /api/items matches schema', async ({request}) => {
  const resp = await request.get('/api/items')
  expect(resp.ok()).toBeTruthy()

  const body = await resp.json()
  const result = PaginatedItemsSchema.safeParse(body)

  if (!result.success) {
    throw new Error(
      `Schema validation failed:\n${result.error.issues
        .map((i) => `  ${i.path.join('.')}: ${i.message}`)
        .join('\n')}`,
    )
  }
})

Decision Guide

ScenarioUse API TestsUse E2E TestsWhy
Validate response status/body/headersYesNoNo browser needed; 10-100x faster
Test business logic (calculations, rules)YesNoAPI tests isolate backend logic from UI
Verify form submission creates correct dataSeed via API, submit via UIYesUI test validates the form; API check confirms persistence
Test error messages shown to userNoYesError rendering is a UI concern
Validate pagination, filtering, sortingYesMaybe bothAPI test for correctness; E2E test only if the UI logic is complex
Seed test data for E2E testsYes (fixture)NoAPI seeding is fast and reliable
Test auth flows (login/logout/RBAC)Yes for token/session logicYes for UI flowBoth matter: API protects resources, UI guides users
Verify file upload processingYesOnly if testing file picker UIAPI test validates backend processing
Contract/schema regression testingYesNoSchema tests run in milliseconds
Test third-party webhook handlingYesNoWebhooks are API-to-API; no UI involved
Verify redirect behavior after actionNoYesRedirects are browser/navigation concerns
Test real-time updates (WebSocket + API trigger)API triggersE2E verifiesSeed via API, observe in browser

Anti-Patterns

Don't Do ThisProblemDo This Instead
Use E2E tests to validate pure API responsesSlow, flaky, launches a browser for no reasonUse request fixture — no browser, direct HTTP
Ignore response.status()A 500 with a fallback body can pass all body assertionsAlways assert status first: expect(response.status()).toBe(200)
Skip response header checksMissing Content-Type, Cache-Control, CORS headers cause production bugsAssert critical headers
Only test the happy pathReal users trigger 400, 401, 403, 404, 409, 422 — every one needs a testDedicate a describe block to error responses
Hardcode IDs in API testsTests break when database is reset or IDs are reassignedCreate resources in the test, use returned IDs
Share mutable state between testsTests that depend on execution order are flaky and cannot run in parallelEach test creates and cleans up its own data
Parse response.text() then JSON.parse() manuallyPlaywright's response.json() handles this and throws clear errors on non-JSONUse await response.json()
Forget cleanup after creating resourcesTest pollution: subsequent tests may see stale data or hit unique constraintsUse fixtures with teardown or explicit delete calls
Use page.request when you don't need a pagepage.request shares cookies with the browser context, which may cause auth confusionUse 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 config to start it automatically.

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 resp = await request.get('/api/endpoint')
if (!resp.ok()) {
  console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`)
}
const body = await resp.json()

"401 Unauthorized" when using request fixture

Cause: The built-in request fixture does not carry browser cookies or auth tokens automatically.

Fix: Set extraHTTPHeaders in config or create a custom authenticated fixture. If you need cookies from a browser login, use page.request instead.

typescript
// Option A: config-level headers
export default defineConfig({
  use: {
    extraHTTPHeaders: {Authorization: `Bearer ${process.env.API_TOKEN}`},
  },
})

// Option B: per-request headers
const resp = 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 resp = await page.request.get('/api/profile')
  expect(resp.ok()).toBeTruthy()
})

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. Run database seeds or migrations in globalSetup. Use unique identifiers (timestamps, UUIDs) for test data. Check that the CI baseURL matches the deployed service.