Skip to content

Latest commit

 

History

History
1617 lines (1364 loc) · 49.5 KB

File metadata and controls

1617 lines (1364 loc) · 49.5 KB

API Testing

When to use: Testing REST or GraphQL APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead. Prerequisites: core/configuration.md for baseURL setup, core/fixtures-and-hooks.md for custom fixture patterns.

Quick Reference

// Standalone API test — no browser launched
import { test, expect } from '@playwright/test';

test('GET /api/users returns user list', async ({ request }) => {
  const response = await request.get('/api/users');
  expect(response.status()).toBe(200);
  expect(response.headers()['content-type']).toContain('application/json');
  const body = await response.json();
  expect(body.users).toHaveLength(3);
  expect(body.users[0]).toMatchObject({ id: expect.any(Number), email: expect.any(String) });
});

Patterns

APIRequestContext Basics

Use when: Making HTTP requests in any test — GET, POST, PUT, PATCH, DELETE with headers, query params, and request bodies. Avoid when: You need to test browser-rendered responses (redirects, cookies set via Set-Cookie with HttpOnly). Use a browser test instead.

The request fixture provides a pre-configured APIRequestContext that inherits baseURL from your config. No browser is launched.

TypeScript

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

test('CRUD operations via API', async ({ request }) => {
  // GET with query parameters
  const listResponse = await request.get('/api/users', {
    params: { page: 1, limit: 10, role: 'admin' },
  });
  expect(listResponse.ok()).toBeTruthy();

  // POST with JSON body
  const createResponse = await request.post('/api/users', {
    data: {
      name: 'Jane Doe',
      email: 'jane@example.com',
      role: 'editor',
    },
  });
  expect(createResponse.status()).toBe(201);
  const created = await createResponse.json();

  // PUT — full replacement
  const updateResponse = await request.put(`/api/users/${created.id}`, {
    data: {
      name: 'Jane Smith',
      email: 'jane.smith@example.com',
      role: 'editor',
    },
  });
  expect(updateResponse.ok()).toBeTruthy();

  // PATCH — partial update
  const patchResponse = await request.patch(`/api/users/${created.id}`, {
    data: { role: 'admin' },
  });
  expect(patchResponse.ok()).toBeTruthy();
  const patched = await patchResponse.json();
  expect(patched.role).toBe('admin');

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

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

test('custom headers and auth tokens', async ({ request }) => {
  const response = await request.get('/api/protected/resource', {
    headers: {
      'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
      'X-Request-ID': 'test-correlation-id-123',
      'Accept': 'application/json',
    },
  });
  expect(response.ok()).toBeTruthy();
});

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

JavaScript

const { test, expect } = require('@playwright/test');

test('CRUD operations via API', async ({ request }) => {
  const listResponse = await request.get('/api/users', {
    params: { page: 1, limit: 10, role: 'admin' },
  });
  expect(listResponse.ok()).toBeTruthy();

  const createResponse = await request.post('/api/users', {
    data: {
      name: 'Jane Doe',
      email: 'jane@example.com',
      role: 'editor',
    },
  });
  expect(createResponse.status()).toBe(201);
  const created = await createResponse.json();

  const updateResponse = await request.put(`/api/users/${created.id}`, {
    data: {
      name: 'Jane Smith',
      email: 'jane.smith@example.com',
      role: 'editor',
    },
  });
  expect(updateResponse.ok()).toBeTruthy();

  const patchResponse = await request.patch(`/api/users/${created.id}`, {
    data: { role: 'admin' },
  });
  expect(patchResponse.ok()).toBeTruthy();

  const deleteResponse = await request.delete(`/api/users/${created.id}`);
  expect(deleteResponse.status()).toBe(204);
});

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

API Test Structure

Use when: Writing dedicated API test suites that do not need a browser. Avoid when: You need to assert on UI state after an API call — use a combined test with page and request fixtures.

Structure API tests in their own directory with descriptive describe blocks per resource or domain.

TypeScript

// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';

// No browser is launched — these tests use only the request fixture
test.describe('Users API', () => {
  test.describe('GET /api/users', () => {
    test('returns paginated user list', async ({ request }) => {
      const response = await request.get('/api/users', {
        params: { page: 1, limit: 5 },
      });
      expect(response.status()).toBe(200);
      const body = await response.json();
      expect(body.users.length).toBeLessThanOrEqual(5);
      expect(body.pagination).toMatchObject({
        page: 1,
        limit: 5,
        total: expect.any(Number),
      });
    });

    test('filters by role', async ({ request }) => {
      const response = await request.get('/api/users', {
        params: { role: 'admin' },
      });
      const body = await response.json();
      for (const user of body.users) {
        expect(user.role).toBe('admin');
      }
    });
  });

  test.describe('POST /api/users', () => {
    test('creates a new user with valid data', async ({ request }) => {
      const response = await request.post('/api/users', {
        data: { name: 'Test User', email: `test-${Date.now()}@example.com` },
      });
      expect(response.status()).toBe(201);
      const user = await response.json();
      expect(user).toMatchObject({
        id: expect.any(Number),
        name: 'Test User',
      });
    });

    test('rejects duplicate email', async ({ request }) => {
      const email = `dupe-${Date.now()}@example.com`;
      await request.post('/api/users', { data: { name: 'First', email } });

      const response = await request.post('/api/users', {
        data: { name: 'Second', email },
      });
      expect(response.status()).toBe(409);
      const body = await response.json();
      expect(body.error).toContain('already exists');
    });
  });
});

JavaScript

// tests/api/users.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Users API', () => {
  test.describe('GET /api/users', () => {
    test('returns paginated user list', async ({ request }) => {
      const response = await request.get('/api/users', {
        params: { page: 1, limit: 5 },
      });
      expect(response.status()).toBe(200);
      const body = await response.json();
      expect(body.users.length).toBeLessThanOrEqual(5);
      expect(body.pagination).toMatchObject({
        page: 1,
        limit: 5,
        total: expect.any(Number),
      });
    });
  });

  test.describe('POST /api/users', () => {
    test('creates a new user with valid data', async ({ request }) => {
      const response = await request.post('/api/users', {
        data: { name: 'Test User', email: `test-${Date.now()}@example.com` },
      });
      expect(response.status()).toBe(201);
      const user = await response.json();
      expect(user).toMatchObject({
        id: expect.any(Number),
        name: 'Test User',
      });
    });
  });
});

Config tip: Use a dedicated project for API tests to avoid launching browsers.

// playwright.config.ts — API project runs without a browser
import { defineConfig } from '@playwright/test';

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

Request Fixtures

Use when: Multiple tests need an authenticated API client, or you want to share request configuration (headers, base URL, auth tokens) across a test suite. Avoid when: A single test makes one-off API calls. Use the built-in request fixture directly.

TypeScript

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

type ApiFixtures = {
  authenticatedRequest: APIRequestContext;
  adminRequest: APIRequestContext;
};

export const test = base.extend<ApiFixtures>({
  authenticatedRequest: async ({ playwright }, use) => {
    // Create a fresh context with auth headers
    const context = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`,
        'Accept': 'application/json',
      },
    });
    await use(context);
    await context.dispose();
  },

  adminRequest: async ({ playwright }, use) => {
    // Login via API to get a token, then create authenticated context
    const loginContext = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
    });
    const loginResponse = await loginContext.post('/api/auth/login', {
      data: {
        email: process.env.ADMIN_EMAIL,
        password: process.env.ADMIN_PASSWORD,
      },
    });
    expect(loginResponse.ok()).toBeTruthy();
    const { token } = await loginResponse.json();
    await loginContext.dispose();

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

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

test('admin can list all users', async ({ adminRequest }) => {
  const response = await adminRequest.get('/api/admin/users');
  expect(response.status()).toBe(200);
  const body = await response.json();
  expect(body.users.length).toBeGreaterThan(0);
});

test('admin can delete a user', async ({ adminRequest }) => {
  // Create then delete
  const createResp = await adminRequest.post('/api/users', {
    data: { name: 'To Delete', email: `del-${Date.now()}@example.com` },
  });
  const { id } = await createResp.json();

  const deleteResp = await adminRequest.delete(`/api/users/${id}`);
  expect(deleteResp.status()).toBe(204);
});

JavaScript

// fixtures/api-fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  authenticatedRequest: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`,
        'Accept': 'application/json',
      },
    });
    await use(context);
    await context.dispose();
  },

  adminRequest: async ({ playwright }, use) => {
    const loginContext = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
    });
    const loginResponse = await loginContext.post('/api/auth/login', {
      data: {
        email: process.env.ADMIN_EMAIL,
        password: process.env.ADMIN_PASSWORD,
      },
    });
    expect(loginResponse.ok()).toBeTruthy();
    const { token } = await loginResponse.json();
    await loginContext.dispose();

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

module.exports = { test, expect };
// tests/api/admin.spec.js
const { test, expect } = require('../../fixtures/api-fixtures');

test('admin can list all users', async ({ adminRequest }) => {
  const response = await adminRequest.get('/api/admin/users');
  expect(response.status()).toBe(200);
  const body = await response.json();
  expect(body.users.length).toBeGreaterThan(0);
});

JSON Response Assertions

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

TypeScript

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

test('thorough response validation', async ({ request }) => {
  const response = await request.get('/api/users/42');

  // Status code — always check first
  expect(response.status()).toBe(200);

  // Status category — ok() checks 200-299 range
  expect(response.ok()).toBeTruthy();

  // Response headers
  expect(response.headers()['content-type']).toContain('application/json');
  expect(response.headers()['x-request-id']).toBeDefined();
  expect(response.headers()['cache-control']).toMatch(/max-age=\d+/);

  // Full body parse and deep assertion
  const user = await response.json();

  // Exact match on known fields
  expect(user.id).toBe(42);
  expect(user.name).toBe('Jane Doe');
  expect(user.email).toBe('jane@example.com');

  // Partial match — ignore fields you don't care about
  expect(user).toMatchObject({
    id: 42,
    name: 'Jane Doe',
    role: expect.stringMatching(/^(admin|editor|viewer)$/),
  });

  // Type checks with expect.any()
  expect(user).toMatchObject({
    id: expect.any(Number),
    name: expect.any(String),
    createdAt: expect.any(String),
    permissions: expect.any(Array),
  });

  // Array content
  expect(user.permissions).toEqual(
    expect.arrayContaining(['read', 'write'])
  );
  expect(user.permissions).not.toContain('delete');

  // Nested object assertions
  expect(user.profile).toMatchObject({
    avatar: expect.stringMatching(/^https:\/\//),
    bio: expect.any(String),
  });

  // Date format validation
  expect(new Date(user.createdAt).toISOString()).toBe(user.createdAt);
});

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

  // Array length
  expect(body.users).toHaveLength(10);

  // Every item in array matches shape
  for (const user of body.users) {
    expect(user).toMatchObject({
      id: expect.any(Number),
      name: expect.any(String),
      email: expect.stringContaining('@'),
    });
  }

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

JavaScript

const { test, expect } = require('@playwright/test');

test('thorough response validation', async ({ request }) => {
  const response = await request.get('/api/users/42');

  expect(response.status()).toBe(200);
  expect(response.ok()).toBeTruthy();
  expect(response.headers()['content-type']).toContain('application/json');

  const user = await response.json();

  expect(user).toMatchObject({
    id: 42,
    name: 'Jane Doe',
    role: expect.stringMatching(/^(admin|editor|viewer)$/),
  });

  expect(user).toMatchObject({
    id: expect.any(Number),
    name: expect.any(String),
    createdAt: expect.any(String),
    permissions: expect.any(Array),
  });

  expect(user.permissions).toEqual(
    expect.arrayContaining(['read', 'write'])
  );
});

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

  expect(body.users).toHaveLength(10);
  for (const user of body.users) {
    expect(user).toMatchObject({
      id: expect.any(Number),
      name: expect.any(String),
      email: expect.stringContaining('@'),
    });
  }
});

GraphQL Testing

Use when: Your backend exposes a GraphQL API and you want to test queries, mutations, variables, and error handling. Avoid when: Your API is purely REST. Use the standard HTTP methods instead.

All GraphQL requests go through POST to a single endpoint. Send query, variables, and optionally operationName in the JSON body.

TypeScript

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

const GRAPHQL_ENDPOINT = '/graphql';

test.describe('GraphQL API', () => {
  test('query with variables', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          query GetUser($id: ID!) {
            user(id: $id) {
              id
              name
              email
              posts {
                id
                title
              }
            }
          }
        `,
        variables: { id: '42' },
      },
    });

    expect(response.ok()).toBeTruthy();
    const { data, errors } = await response.json();

    // GraphQL returns 200 even on errors — always check both
    expect(errors).toBeUndefined();
    expect(data.user).toMatchObject({
      id: '42',
      name: expect.any(String),
      email: expect.stringContaining('@'),
    });
    expect(data.user.posts).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(String), title: expect.any(String) }),
      ])
    );
  });

  test('mutation creates a resource', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          mutation CreatePost($input: CreatePostInput!) {
            createPost(input: $input) {
              id
              title
              status
              author {
                id
              }
            }
          }
        `,
        variables: {
          input: {
            title: 'API Testing with Playwright',
            body: 'A comprehensive guide...',
            status: 'DRAFT',
          },
        },
      },
    });

    const { data, errors } = await response.json();
    expect(errors).toBeUndefined();
    expect(data.createPost).toMatchObject({
      id: expect.any(String),
      title: 'API Testing with Playwright',
      status: 'DRAFT',
    });
  });

  test('handles GraphQL validation errors', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          mutation CreatePost($input: CreatePostInput!) {
            createPost(input: $input) {
              id
            }
          }
        `,
        variables: {
          input: { title: '' }, // invalid: empty title
        },
      },
    });

    // GraphQL often returns 200 even for validation errors
    const { data, errors } = await response.json();
    expect(errors).toBeDefined();
    expect(errors.length).toBeGreaterThan(0);
    expect(errors[0].message).toContain('title');
    expect(errors[0].extensions?.code).toBe('BAD_USER_INPUT');
  });

  test('handles authorization errors', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          query AdminDashboard {
            adminStats {
              totalRevenue
              activeUsers
            }
          }
        `,
      },
      // No auth header
    });

    const { data, errors } = await response.json();
    expect(errors).toBeDefined();
    expect(errors[0].extensions?.code).toBe('UNAUTHORIZED');
    expect(data?.adminStats).toBeNull();
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

const GRAPHQL_ENDPOINT = '/graphql';

test.describe('GraphQL API', () => {
  test('query with variables', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          query GetUser($id: ID!) {
            user(id: $id) {
              id
              name
              email
            }
          }
        `,
        variables: { id: '42' },
      },
    });

    const { data, errors } = await response.json();
    expect(errors).toBeUndefined();
    expect(data.user).toMatchObject({
      id: '42',
      name: expect.any(String),
      email: expect.stringContaining('@'),
    });
  });

  test('mutation creates a resource', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          mutation CreatePost($input: CreatePostInput!) {
            createPost(input: $input) {
              id
              title
              status
            }
          }
        `,
        variables: {
          input: {
            title: 'API Testing with Playwright',
            body: 'A comprehensive guide...',
            status: 'DRAFT',
          },
        },
      },
    });

    const { data, errors } = await response.json();
    expect(errors).toBeUndefined();
    expect(data.createPost).toMatchObject({
      id: expect.any(String),
      title: 'API Testing with Playwright',
      status: 'DRAFT',
    });
  });

  test('handles GraphQL validation errors', async ({ request }) => {
    const response = await request.post(GRAPHQL_ENDPOINT, {
      data: {
        query: `
          mutation CreatePost($input: CreatePostInput!) {
            createPost(input: $input) { id }
          }
        `,
        variables: { input: { title: '' } },
      },
    });

    const { data, errors } = await response.json();
    expect(errors).toBeDefined();
    expect(errors.length).toBeGreaterThan(0);
    expect(errors[0].message).toContain('title');
  });
});

API Data Seeding

Use when: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup. Avoid when: The test specifically validates the creation flow through the UI. Seed everything except what you are testing.

TypeScript

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

// Fixture that seeds data via API before each test
type SeedFixtures = {
  seedUser: { id: number; email: string; password: string };
  seedProject: { id: number; name: string };
};

export const test = base.extend<SeedFixtures>({
  seedUser: async ({ request }, use) => {
    const email = `user-${Date.now()}@example.com`;
    const password = 'TestPass123!';

    // Create via API
    const response = await request.post('/api/users', {
      data: { name: 'Test User', email, password },
    });
    expect(response.ok()).toBeTruthy();
    const user = await response.json();

    // Pass to test
    await use({ id: user.id, email, password });

    // Cleanup after test — always delete what you created
    await request.delete(`/api/users/${user.id}`);
  },

  seedProject: async ({ request, seedUser }, use) => {
    const response = await request.post('/api/projects', {
      data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id },
    });
    expect(response.ok()).toBeTruthy();
    const project = await response.json();

    await use({ id: project.id, name: project.name });

    await request.delete(`/api/projects/${project.id}`);
  },
});

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

test('user sees their project on dashboard', async ({ page, seedUser, seedProject }) => {
  // Login via UI (or use storageState for speed)
  await page.goto('/login');
  await page.getByLabel('Email').fill(seedUser.email);
  await page.getByLabel('Password').fill(seedUser.password);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Data already exists — go straight to assertion
  await page.waitForURL('/dashboard');
  await expect(page.getByRole('heading', { name: seedProject.name })).toBeVisible();
});

JavaScript

// fixtures/seed-fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  seedUser: async ({ request }, use) => {
    const email = `user-${Date.now()}@example.com`;
    const password = 'TestPass123!';

    const response = await request.post('/api/users', {
      data: { name: 'Test User', email, password },
    });
    expect(response.ok()).toBeTruthy();
    const user = await response.json();

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

    await request.delete(`/api/users/${user.id}`);
  },

  seedProject: async ({ request, seedUser }, use) => {
    const response = await request.post('/api/projects', {
      data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id },
    });
    expect(response.ok()).toBeTruthy();
    const project = await response.json();

    await use({ id: project.id, name: project.name });

    await request.delete(`/api/projects/${project.id}`);
  },
});

module.exports = { test, expect };
// tests/e2e/project-dashboard.spec.js
const { test, expect } = require('../../fixtures/seed-fixtures');

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

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

Schema Validation

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

Option A: Zod (recommended for TypeScript projects)

TypeScript

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

// Define schemas once, reuse across tests
const UserSchema = z.object({
  id: z.number().positive(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
  profile: z.object({
    avatar: z.string().url().nullable(),
    bio: z.string().max(500),
  }),
});

const PaginatedUsersSchema = z.object({
  users: z.array(UserSchema),
  pagination: z.object({
    page: z.number().int().positive(),
    limit: z.number().int().positive(),
    total: z.number().int().nonnegative(),
  }),
});

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

  const body = await response.json();
  const result = PaginatedUsersSchema.safeParse(body);

  if (!result.success) {
    // Detailed error output showing exactly which fields failed
    throw new Error(
      `Schema validation failed:\n${result.error.issues
        .map((i) => `  ${i.path.join('.')}: ${i.message}`)
        .join('\n')}`
    );
  }
});

test('POST /api/users returns valid user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Schema Test', email: `schema-${Date.now()}@example.com` },
  });

  const body = await response.json();
  // Throws with detailed path info if validation fails
  UserSchema.parse(body);
});

Option B: Manual type checks (no dependencies)

TypeScript

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

function assertUserShape(user: unknown): void {
  expect(user).toBeDefined();
  expect(user).toMatchObject({
    id: expect.any(Number),
    name: expect.any(String),
    email: expect.any(String),
    role: expect.any(String),
    createdAt: expect.any(String),
  });

  const u = user as Record<string, unknown>;
  // Value constraints
  expect(['admin', 'editor', 'viewer']).toContain(u.role);
  expect(typeof u.email === 'string' && u.email.includes('@')).toBe(true);
  expect(new Date(u.createdAt as string).toString()).not.toBe('Invalid Date');
}

test('response matches expected shape', async ({ request }) => {
  const response = await request.get('/api/users/1');
  expect(response.ok()).toBeTruthy();

  const body = await response.json();
  assertUserShape(body);
});

JavaScript

const { test, expect } = require('@playwright/test');

function assertUserShape(user) {
  expect(user).toBeDefined();
  expect(user).toMatchObject({
    id: expect.any(Number),
    name: expect.any(String),
    email: expect.any(String),
    role: expect.any(String),
    createdAt: expect.any(String),
  });
  expect(['admin', 'editor', 'viewer']).toContain(user.role);
  expect(user.email).toContain('@');
  expect(new Date(user.createdAt).toString()).not.toBe('Invalid Date');
}

test('response matches expected shape', async ({ request }) => {
  const response = await request.get('/api/users/1');
  expect(response.ok()).toBeTruthy();

  const body = await response.json();
  assertUserShape(body);
});

Error Response Testing

Use when: Every API has error paths. Test them. A missing 401 test today is a security hole tomorrow. Avoid when: Never skip error testing.

TypeScript

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

test.describe('Error responses', () => {
  test('400 — validation error with details', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: { name: '', email: 'not-an-email' }, // invalid
    });
    expect(response.status()).toBe(400);

    const body = await response.json();
    expect(body).toMatchObject({
      error: 'Validation Error',
      details: expect.any(Array),
    });
    // Check individual field errors
    expect(body.details).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ field: 'name', message: expect.any(String) }),
        expect.objectContaining({ field: 'email', message: expect.any(String) }),
      ])
    );
  });

  test('401 — missing authentication', async ({ request }) => {
    // Create a fresh context with NO auth headers
    const response = await request.get('/api/protected/resource', {
      headers: { 'Authorization': '' }, // explicitly clear
    });
    expect(response.status()).toBe(401);

    const body = await response.json();
    expect(body.error).toMatch(/unauthorized|unauthenticated/i);
  });

  test('403 — insufficient permissions', async ({ request }) => {
    // Assuming `request` is authenticated as a viewer
    const response = await request.delete('/api/admin/users/1');
    expect(response.status()).toBe(403);

    const body = await response.json();
    expect(body.error).toMatch(/forbidden|insufficient permissions/i);
  });

  test('404 — resource not found', async ({ request }) => {
    const response = await request.get('/api/users/999999');
    expect(response.status()).toBe(404);

    const body = await response.json();
    expect(body).toMatchObject({
      error: expect.stringMatching(/not found/i),
    });
  });

  test('409 — conflict on duplicate resource', async ({ request }) => {
    const email = `conflict-${Date.now()}@example.com`;
    await request.post('/api/users', {
      data: { name: 'First', email },
    });

    const response = await request.post('/api/users', {
      data: { name: 'Duplicate', email },
    });
    expect(response.status()).toBe(409);
  });

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

  test('429 — rate limiting', async ({ request }) => {
    // Send rapid requests to trigger rate limit
    const responses = await Promise.all(
      Array.from({ length: 50 }, () =>
        request.get('/api/search', { params: { q: 'test' } })
      )
    );
    const rateLimited = responses.filter((r) => r.status() === 429);
    expect(rateLimited.length).toBeGreaterThan(0);

    // Verify rate limit headers
    const limited = rateLimited[0];
    expect(limited.headers()['retry-after']).toBeDefined();
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('Error responses', () => {
  test('400 — validation error with details', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: { name: '', email: 'not-an-email' },
    });
    expect(response.status()).toBe(400);

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

  test('401 — missing authentication', async ({ request }) => {
    const response = await request.get('/api/protected/resource', {
      headers: { 'Authorization': '' },
    });
    expect(response.status()).toBe(401);
  });

  test('404 — resource not found', async ({ request }) => {
    const response = await request.get('/api/users/999999');
    expect(response.status()).toBe(404);
  });

  test('409 — conflict on duplicate resource', async ({ request }) => {
    const email = `conflict-${Date.now()}@example.com`;
    await request.post('/api/users', { data: { name: 'First', email } });

    const response = await request.post('/api/users', {
      data: { name: 'Duplicate', email },
    });
    expect(response.status()).toBe(409);
  });
});

File Upload via API

Use when: Testing file upload endpoints with multipart form data — document uploads, image processing, CSV imports. Avoid when: You need to test the browser file picker dialog. Use page.setInputFiles() in an E2E test instead.

TypeScript

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

test('upload a file via multipart form data', async ({ request }) => {
  const filePath = path.resolve('tests/fixtures/test-document.pdf');

  const response = await request.post('/api/documents/upload', {
    multipart: {
      file: {
        name: 'test-document.pdf',
        mimeType: 'application/pdf',
        buffer: fs.readFileSync(filePath),
      },
      description: 'Quarterly report',
      category: 'reports',
    },
  });

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

test('upload an image with metadata', async ({ request }) => {
  const imagePath = path.resolve('tests/fixtures/avatar.png');

  const response = await request.post('/api/users/42/avatar', {
    multipart: {
      image: {
        name: 'avatar.png',
        mimeType: 'image/png',
        buffer: fs.readFileSync(imagePath),
      },
      crop: JSON.stringify({ x: 0, y: 0, width: 200, height: 200 }),
    },
  });

  expect(response.ok()).toBeTruthy();
  const body = await response.json();
  expect(body.avatarUrl).toMatch(/\.png$/);
});

test('upload multiple files', async ({ request }) => {
  const files = ['report-q1.csv', 'report-q2.csv'].map((name) => ({
    name,
    mimeType: 'text/csv',
    buffer: fs.readFileSync(path.resolve(`tests/fixtures/${name}`)),
  }));

  // Send sequential uploads when the API does not support batch
  const results = [];
  for (const file of files) {
    const response = await request.post('/api/imports/csv', {
      multipart: { file },
    });
    expect(response.ok()).toBeTruthy();
    results.push(await response.json());
  }
  expect(results).toHaveLength(2);
});

test('rejects oversized files', async ({ request }) => {
  // Create a buffer that exceeds the server limit
  const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB

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

  expect(response.status()).toBe(413); // Payload Too Large
});

JavaScript

const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');

test('upload a file via multipart form data', async ({ request }) => {
  const filePath = path.resolve('tests/fixtures/test-document.pdf');

  const response = await request.post('/api/documents/upload', {
    multipart: {
      file: {
        name: 'test-document.pdf',
        mimeType: 'application/pdf',
        buffer: fs.readFileSync(filePath),
      },
      description: 'Quarterly report',
      category: 'reports',
    },
  });

  expect(response.status()).toBe(201);
  const body = await response.json();
  expect(body).toMatchObject({
    id: expect.any(String),
    filename: 'test-document.pdf',
    mimeType: 'application/pdf',
    size: expect.any(Number),
  });
});

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

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

  expect(response.status()).toBe(413);
});

Chained API Calls

Use when: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions. This verifies the API's behavior as an integrated system, not just isolated endpoints. Avoid when: You can test each endpoint in isolation and the interactions are trivial.

TypeScript

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

test('complete order workflow', async ({ request }) => {
  // Step 1: Create a product
  const productResp = await request.post('/api/products', {
    data: { name: 'Widget', price: 29.99, stock: 100 },
  });
  expect(productResp.status()).toBe(201);
  const product = await productResp.json();

  // Step 2: Create a cart
  const cartResp = await request.post('/api/carts', {
    data: { items: [{ productId: product.id, quantity: 2 }] },
  });
  expect(cartResp.status()).toBe(201);
  const cart = await cartResp.json();
  expect(cart.total).toBe(59.98);

  // Step 3: Checkout — create an order from the cart
  const orderResp = await request.post('/api/orders', {
    data: {
      cartId: cart.id,
      shippingAddress: {
        street: '123 Test St',
        city: 'Testville',
        zip: '12345',
      },
    },
  });
  expect(orderResp.status()).toBe(201);
  const order = await orderResp.json();
  expect(order.status).toBe('pending');
  expect(order.items).toHaveLength(1);
  expect(order.total).toBe(59.98);

  // Step 4: Verify order appears in user's order list
  const ordersResp = await request.get('/api/orders');
  const orders = await ordersResp.json();
  expect(orders.items.map((o: any) => o.id)).toContain(order.id);

  // Step 5: Verify product stock decreased
  const updatedProduct = await (await request.get(`/api/products/${product.id}`)).json();
  expect(updatedProduct.stock).toBe(98); // 100 - 2

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

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

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

  // Approve (requires admin — use appropriate fixture in real tests)
  const approveResp = await request.patch(`/api/posts/${post.id}/status`, {
    data: { status: 'published' },
  });
  expect(approveResp.ok()).toBeTruthy();
  expect((await approveResp.json()).status).toBe('published');

  // Verify: cannot go back to draft from published
  const revertResp = await request.patch(`/api/posts/${post.id}/status`, {
    data: { status: 'draft' },
  });
  expect(revertResp.status()).toBe(422);

  // Cleanup
  await request.delete(`/api/posts/${post.id}`);
});

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

  // Verify via browser
  await page.goto('/products');
  await expect(
    page.getByRole('heading', { name: product.name })
  ).toBeVisible();
  await expect(page.getByText('$42.00')).toBeVisible();

  // Cleanup via API
  await request.delete(`/api/products/${product.id}`);
});

JavaScript

const { test, expect } = require('@playwright/test');

test('complete order workflow', async ({ request }) => {
  const productResp = await request.post('/api/products', {
    data: { name: 'Widget', price: 29.99, stock: 100 },
  });
  expect(productResp.status()).toBe(201);
  const product = await productResp.json();

  const cartResp = await request.post('/api/carts', {
    data: { items: [{ productId: product.id, quantity: 2 }] },
  });
  expect(cartResp.status()).toBe(201);
  const cart = await cartResp.json();
  expect(cart.total).toBe(59.98);

  const orderResp = await request.post('/api/orders', {
    data: {
      cartId: cart.id,
      shippingAddress: {
        street: '123 Test St',
        city: 'Testville',
        zip: '12345',
      },
    },
  });
  expect(orderResp.status()).toBe(201);
  const order = await orderResp.json();
  expect(order.status).toBe('pending');

  const ordersResp = await request.get('/api/orders');
  const orders = await ordersResp.json();
  expect(orders.items.map((o) => o.id)).toContain(order.id);

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

test('API + E2E hybrid — seed via API, verify in browser', async ({ request, page }) => {
  const resp = await request.post('/api/products', {
    data: {
      name: `Hybrid Test Product ${Date.now()}`,
      price: 42.00,
      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.
// 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.
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.
// Option A: config-level headers
export default defineConfig({
  use: {
    extraHTTPHeaders: {
      'Authorization': `Bearer ${process.env.API_TOKEN}`,
    },
  },
});

// Option B: per-request headers
const response = await request.get('/api/resource', {
  headers: { 'Authorization': `Bearer ${token}` },
});

// Option C: use page.request to inherit browser cookies
test('API call with browser auth', async ({ page }) => {
  await page.goto('/login');
  // ... login via UI ...
  const response = await page.request.get('/api/profile');
  expect(response.ok()).toBeTruthy();
});

GraphQL returns 200 but data is null

Cause: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the errors array.

Fix: Always destructure and check both data and errors.

const { data, errors } = await response.json();
if (errors) {
  console.error('GraphQL errors:', JSON.stringify(errors, null, 2));
}
expect(errors).toBeUndefined();
expect(data.user).toBeDefined();

Tests pass locally but fail in CI

Cause: Different environments, database state, or missing environment variables.

Fix:

  • Use process.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