A 200 status code with wrong field names is still a bug, and most basic API test suites miss it because they only assert the status. At scale, the patterns that keep suites reliable are fixture-based authentication (inject tokens, don't repeat login code in every test), schema validation against actual field types and required fields, and full CRUD lifecycle coverage that verifies deletion is real, not just a 204 response. This article covers each pattern with complete Playwright examples.

The request Fixture vs page.request

Two ways to make API calls in Playwright:

// 1. The request fixture — no browser, pure API
test('API test', async ({ request }) => {
  const response = await request.get('/api/users');
});

// 2. page.request — shares cookies with the browser page
test('API after browser login', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@test.com');
  await page.fill('[data-testid="password"]', 'ValidPass1');
  await page.click('[data-testid="submit"]');
  
  // This request uses the browser's session (cookies set by login)
  const response = await page.request.get('/api/user/profile');
});

Use request fixture for pure API tests. Use page.request when you need the browser's authenticated session.

Authentication Patterns

Token-based auth

test.describe('Authenticated API tests', () => {
  let authToken: string;
  
  test.beforeAll(async ({ request }) => {
    const response = await request.post('/api/auth/login', {
      data: {
        email: 'admin@test.com',
        password: 'AdminPass1',
      },
    });
    
    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    authToken = body.token;
  });
  
  test('get users list', async ({ request }) => {
    const response = await request.get('/api/users', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    expect(response.status()).toBe(200);
  });
  
  test('create user', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: { email: 'new@test.com', password: 'Pass1', role: 'member' },
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    expect(response.status()).toBe(201);
  });
});

Using fixtures for auth

Better than beforeAll — fixtures are cleaner and handle teardown:

// fixtures/index.ts
export const test = base.extend<{ authToken: string }>({
  authToken: async ({ request }, use) => {
    const response = await request.post('/api/auth/login', {
      data: { email: 'admin@test.com', password: 'AdminPass1' },
    });
    const { token } = await response.json();
    await use(token);
    // No teardown needed for tokens — they expire or are revoked naturally
  },
});

Schema Validation

Verify response structure, not just status codes:

import Ajv from 'ajv';

const ajv = new Ajv();

const userSchema = {
  type: 'object',
  required: ['id', 'email', 'role', 'createdAt'],
  properties: {
    id: { type: 'number' },
    email: { type: 'string', format: 'email' },
    role: { type: 'string', enum: ['admin', 'member', 'viewer'] },
    createdAt: { type: 'string' },
    name: { type: 'string' },
  },
  additionalProperties: false,
};

test('user response matches schema', async ({ request, authToken }) => {
  const response = await request.get('/api/users/1', {
    headers: { Authorization: `Bearer ${authToken}` },
  });
  
  const body = await response.json();
  const validate = ajv.compile(userSchema);
  const valid = validate(body);
  
  if (!valid) {
    throw new Error(`Schema validation failed: ${JSON.stringify(validate.errors)}`);
  }
  
  expect(valid).toBe(true);
});

Without a library, use manual checks:

function validateUserSchema(body: unknown) {
  const user = body as Record<string, unknown>;
  
  expect(typeof user.id).toBe('number');
  expect(typeof user.email).toBe('string');
  expect(user.email).toMatch(/@/);
  expect(['admin', 'member', 'viewer']).toContain(user.role);
  expect(typeof user.createdAt).toBe('string');
  expect(() => new Date(user.createdAt as string)).not.toThrow();
}

test('user schema validation', async ({ request, authToken }) => {
  const response = await request.get('/api/users/1', {
    headers: { Authorization: `Bearer ${authToken}` },
  });
  
  const body = await response.json();
  validateUserSchema(body);
});

CRUD Test Lifecycle

Test the full create → read → update → delete cycle:

test.describe('User CRUD', () => {
  const authHeaders = () => ({
    Authorization: `Bearer ${authToken}`,
  });
  
  let createdUserId: number;
  const userData = {
    email: `crud_test_${Date.now()}@test.com`,
    password: 'ValidPass1',
    name: 'CRUD Test User',
    role: 'member',
  };
  
  test('CREATE — POST /api/users', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: userData,
      headers: authHeaders(),
    });
    
    expect(response.status()).toBe(201);
    
    const body = await response.json();
    expect(body.email).toBe(userData.email);
    expect(body.id).toBeDefined();
    expect(body.password).toBeUndefined();  // Should never return password
    
    createdUserId = body.id;
  });
  
  test('READ — GET /api/users/:id', async ({ request }) => {
    const response = await request.get(`/api/users/${createdUserId}`, {
      headers: authHeaders(),
    });
    
    expect(response.status()).toBe(200);
    
    const body = await response.json();
    expect(body.id).toBe(createdUserId);
    expect(body.email).toBe(userData.email);
  });
  
  test('UPDATE — PUT /api/users/:id', async ({ request }) => {
    const response = await request.put(`/api/users/${createdUserId}`, {
      data: { name: 'Updated Name' },
      headers: authHeaders(),
    });
    
    expect(response.status()).toBe(200);
    
    const body = await response.json();
    expect(body.name).toBe('Updated Name');
    expect(body.email).toBe(userData.email);  // Email unchanged
  });
  
  test('DELETE — DELETE /api/users/:id', async ({ request }) => {
    const response = await request.delete(`/api/users/${createdUserId}`, {
      headers: authHeaders(),
    });
    
    expect(response.status()).toBe(204);  // No content
  });
  
  test('VERIFY DELETED — GET returns 404', async ({ request }) => {
    const response = await request.get(`/api/users/${createdUserId}`, {
      headers: authHeaders(),
    });
    
    expect(response.status()).toBe(404);
  });
});

Testing Error Cases

Don't just test the happy path:

test.describe('Error handling', () => {
  test('401 — missing auth token', async ({ request }) => {
    const response = await request.get('/api/users');
    expect(response.status()).toBe(401);
    
    const body = await response.json();
    expect(body.error).toBeDefined();
  });
  
  test('403 — insufficient permissions', async ({ request, memberToken }) => {
    // Member trying to access admin endpoint
    const response = await request.get('/api/admin/logs', {
      headers: { Authorization: `Bearer ${memberToken}` },
    });
    expect(response.status()).toBe(403);
  });
  
  test('404 — non-existent resource', async ({ request, authToken }) => {
    const response = await request.get('/api/users/999999', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    expect(response.status()).toBe(404);
    
    const body = await response.json();
    expect(body.message).toContain('not found');
  });
  
  test('400 — invalid request body', async ({ request, authToken }) => {
    const response = await request.post('/api/users', {
      data: { email: 'not-an-email', password: '123' },  // Invalid
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    expect(response.status()).toBe(400);
    
    const body = await response.json();
    expect(body.errors).toBeDefined();
    expect(body.errors).toBeInstanceOf(Array);
  });
  
  test('409 — duplicate resource', async ({ request, authToken }) => {
    const userData = { email: 'duplicate@test.com', password: 'ValidPass1' };
    
    // Create first
    await request.post('/api/users', {
      data: userData,
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    // Create duplicate
    const response = await request.post('/api/users', {
      data: userData,
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    expect(response.status()).toBe(409);
  });
});

Pagination Testing

test.describe('Pagination', () => {
  test('returns correct page size', async ({ request, authToken }) => {
    const response = await request.get('/api/users?page=1&limit=10', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    const body = await response.json();
    
    expect(body.data).toHaveLength(10);
    expect(body.page).toBe(1);
    expect(body.limit).toBe(10);
    expect(typeof body.total).toBe('number');
    expect(typeof body.totalPages).toBe('number');
  });
  
  test('last page has fewer items', async ({ request, authToken }) => {
    // Get total count first
    const firstResp = await request.get('/api/users?page=1&limit=10', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const { total, totalPages } = await firstResp.json();
    
    // Get last page
    const lastResp = await request.get(`/api/users?page=${totalPages}&limit=10`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const lastPage = await lastResp.json();
    
    const expectedLastPageCount = total % 10 || 10;
    expect(lastPage.data).toHaveLength(expectedLastPageCount);
  });
  
  test('page beyond total returns empty', async ({ request, authToken }) => {
    const response = await request.get('/api/users?page=9999&limit=10', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    
    const body = await response.json();
    expect(body.data).toHaveLength(0);
  });
});

Combining API Setup with UI Tests

The most powerful pattern — use API to set up state, then verify via UI:

test('newly created user appears in admin panel', async ({ page, request, authToken }) => {
  // 1. Create user via API (fast, reliable)
  const userData = { 
    email: `new_${Date.now()}@test.com`, 
    password: 'ValidPass1',
    name: 'New API User',
    role: 'member',
  };
  
  const createResp = await request.post('/api/users', {
    data: userData,
    headers: { Authorization: `Bearer ${authToken}` },
  });
  const { id } = await createResp.json();
  
  // 2. Verify via UI (what users actually see)
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'admin@test.com');
  await page.fill('[data-testid="password"]', 'AdminPass1');
  await page.click('[data-testid="submit"]');
  
  await page.goto('/admin/users');
  await page.fill('[data-testid="search"]', userData.email);
  
  await expect(page.getByTestId('user-row').first()).toContainText(userData.name);
  
  // 3. Cleanup via API (fast, reliable)
  await request.delete(`/api/users/${id}`, {
    headers: { Authorization: `Bearer ${authToken}` },
  });
});

Response Time Assertions

test('list endpoint responds within SLA', async ({ request, authToken }) => {
  const startTime = Date.now();
  
  const response = await request.get('/api/users?limit=100', {
    headers: { Authorization: `Bearer ${authToken}` },
  });
  
  const duration = Date.now() - startTime;
  
  expect(response.status()).toBe(200);
  expect(duration).toBeLessThan(500);  // 500ms SLA
});

Summary

Patterns that hold up at scale:

1. Use fixtures for authentication tokens — inject them, don't repeat login code

2. Validate schemas — status 200 with wrong fields is still a bug

3. Test CRUD in order — create, read, update, delete as a lifecycle

4. Test error cases — 401, 403, 404, 400, 409 are as important as 200

5. Use API for setup/teardown in UI tests — faster and more reliable than UI-based setup

6. Isolate data — unique email per test, delete after

The combination of API tests (fast, thorough) and UI tests (realistic user paths) gives you the best coverage with the most confidence.

→ See also: API Testing with Playwright's APIRequestContext (No Postman Required) | Authentication in API Tests: API Keys, Bearer Tokens, OAuth2, JWT | GraphQL API Testing with Playwright: Queries, Mutations, and Error Handling