API Testing with Playwright

Test your backend APIs directly without a browser

9 min readNode.js

API Testing with Playwright

In the previous tutorial, we learned how to mock network requests. Now here's a plot twist: Playwright isn't just for browser testing.

It has a powerful API testing library built in. You can test REST APIs, set up test data before UI tests, and combine API and UI testing in the same test suite. Same framework, same syntax, no extra dependencies. How cool is that?

Why API Testing in Playwright?

One framework for everything — No need for separate tools like Postman, Insomnia, or dedicated API test libraries.

Reuse auth state — The same storageState from browser tests works for API requests.

Hybrid testing — Create data via API (fast), verify in UI (realistic).

Parallel execution — Same parallelization as browser tests.

Familiar assertions — Use expect for everything.

The Request Fixture

The request fixture gives you an API client:

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

test('basic API test', async ({ request }) => {
  const response = await request.get('https://api.example.com/users');
  
  expect(response.ok()).toBeTruthy();
  expect(response.status()).toBe(200);
});

The request fixture automatically:

  • Handles cookies and authentication
  • Follows redirects
  • Respects baseURL from config

Making Requests

GET Requests

test('GET request', async ({ request }) => {
  // Simple GET
  const response = await request.get('/api/users');
  expect(response.ok()).toBeTruthy();
  
  // Parse JSON response
  const users = await response.json();
  expect(users.length).toBeGreaterThan(0);
  expect(users[0]).toHaveProperty('email');
});

test('GET with query params', async ({ request }) => {
  const response = await request.get('/api/users', {
    params: {
      page: 1,
      limit: 10,
      sort: 'name',
    },
  });
  // Sends: /api/users?page=1&limit=10&sort=name
  
  const data = await response.json();
  expect(data.users.length).toBeLessThanOrEqual(10);
});

POST Requests

test('POST JSON data', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'Alice Smith',
      email: 'alice@example.com',
      role: 'user',
    },
  });
  
  expect(response.status()).toBe(201);
  
  const user = await response.json();
  expect(user.id).toBeDefined();
  expect(user.name).toBe('Alice Smith');
});

test('POST form data', async ({ request }) => {
  const response = await request.post('/api/upload', {
    form: {
      title: 'My Document',
      description: 'Test upload',
    },
  });
  
  expect(response.ok()).toBeTruthy();
});

test('POST multipart form (file upload)', async ({ request }) => {
  const response = await request.post('/api/files', {
    multipart: {
      name: 'test-file',
      file: {
        name: 'document.pdf',
        mimeType: 'application/pdf',
        buffer: Buffer.from('fake pdf content'),
      },
    },
  });
  
  expect(response.status()).toBe(201);
});

PUT Requests

test('PUT to update resource', async ({ request }) => {
  const response = await request.put('/api/users/123', {
    data: {
      name: 'Alice Johnson',
      email: 'alice.johnson@example.com',
    },
  });
  
  expect(response.ok()).toBeTruthy();
  
  const user = await response.json();
  expect(user.name).toBe('Alice Johnson');
});

PATCH Requests

test('PATCH for partial update', async ({ request }) => {
  const response = await request.patch('/api/users/123', {
    data: {
      name: 'Updated Name',
      // Only updating name, other fields unchanged
    },
  });
  
  expect(response.ok()).toBeTruthy();
});

DELETE Requests

test('DELETE resource', async ({ request }) => {
  const response = await request.delete('/api/users/123');
  
  expect(response.status()).toBe(204); // No content
  
  // Verify deletion
  const getResponse = await request.get('/api/users/123');
  expect(getResponse.status()).toBe(404);
});

Request Options

Custom Headers

test('request with headers', async ({ request }) => {
  const response = await request.get('/api/data', {
    headers: {
      'Accept': 'application/json',
      'X-Custom-Header': 'custom-value',
      'Authorization': 'Bearer my-token',
    },
  });
  
  expect(response.ok()).toBeTruthy();
});

Timeout

test('request with timeout', async ({ request }) => {
  const response = await request.get('/api/slow-endpoint', {
    timeout: 30000, // 30 seconds
  });
  
  expect(response.ok()).toBeTruthy();
});

Ignoring HTTPS Errors

test('ignore SSL errors', async ({ request }) => {
  const response = await request.get('https://self-signed.example.com/api', {
    ignoreHTTPSErrors: true,
  });
  
  expect(response.ok()).toBeTruthy();
});

Asserting Responses

Status and OK

test('response assertions', async ({ request }) => {
  const response = await request.get('/api/users');
  
  // Check status
  expect(response.status()).toBe(200);
  expect(response.ok()).toBeTruthy(); // 2xx status
  
  // Status text
  expect(response.statusText()).toBe('OK');
});

Response Body

test('JSON body assertions', async ({ request }) => {
  const response = await request.get('/api/users/1');
  const user = await response.json();
  
  // Property exists
  expect(user).toHaveProperty('id');
  expect(user).toHaveProperty('email');
  
  // Exact value
  expect(user.name).toBe('Alice');
  
  // Partial match
  expect(user).toMatchObject({
    id: 1,
    name: 'Alice',
  });
  
  // Array assertions
  expect(user.roles).toContain('admin');
  expect(user.roles).toHaveLength(2);
});

test('text body', async ({ request }) => {
  const response = await request.get('/api/status');
  const text = await response.text();
  
  expect(text).toContain('OK');
});

test('binary body', async ({ request }) => {
  const response = await request.get('/api/files/document.pdf');
  const buffer = await response.body();
  
  expect(buffer.length).toBeGreaterThan(0);
});

Response Headers

test('header assertions', async ({ request }) => {
  const response = await request.get('/api/users');
  
  // Get specific header
  expect(response.headers()['content-type']).toContain('application/json');
  
  // Check header exists
  expect(response.headers()).toHaveProperty('x-request-id');
  
  // All headers
  const headers = response.headersArray();
  expect(headers.some(h => h.name === 'Content-Type')).toBeTruthy();
});

Response URL

test('URL after redirects', async ({ request }) => {
  const response = await request.get('/old-path');
  
  // Get final URL after redirects
  expect(response.url()).toBe('https://api.example.com/new-path');
});

Authenticated Requests

Using Storage State

If you've set up authentication with storageState, API requests automatically include cookies:

// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: 'https://api.example.com',
    storageState: '.auth/user.json',
  },
});
// Tests automatically use auth cookies
test('authenticated API call', async ({ request }) => {
  const response = await request.get('/api/me');
  expect(response.ok()).toBeTruthy();
  
  const user = await response.json();
  expect(user.email).toBe('user@example.com');
});

Manual Authentication

test('API with bearer token', async ({ request }) => {
  const response = await request.get('/api/protected', {
    headers: {
      Authorization: `Bearer ${process.env.API_TOKEN}`,
    },
  });
  
  expect(response.ok()).toBeTruthy();
});

Login via API

test('login via API', async ({ request }) => {
  // Login
  const loginResponse = await request.post('/api/auth/login', {
    data: {
      email: 'user@example.com',
      password: 'password123',
    },
  });
  
  expect(loginResponse.ok()).toBeTruthy();
  const { token } = await loginResponse.json();
  
  // Use token for subsequent requests
  const profileResponse = await request.get('/api/me', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
  
  expect(profileResponse.ok()).toBeTruthy();
});

Creating a Standalone API Request Context

For tests that don't need a browser:

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

test('standalone API test', async () => {
  // Create a new request context
  const apiContext = await request.newContext({
    baseURL: 'https://api.example.com',
    extraHTTPHeaders: {
      'Authorization': `Bearer ${process.env.API_TOKEN}`,
    },
  });
  
  const response = await apiContext.get('/users');
  expect(response.ok()).toBeTruthy();
  
  // Clean up
  await apiContext.dispose();
});

Combining API and UI Tests

"Can I use API calls to set up data and then verify it in the browser?"

This is the real power: use API for fast setup, UI for realistic verification.

Setup Test Data via API

test('create user via API, verify in UI', async ({ page, request }) => {
  // Create user via API (fast)
  const createResponse = await request.post('/api/users', {
    data: {
      name: 'Test User',
      email: `test-${Date.now()}@example.com`,
      role: 'admin',
    },
  });
  
  expect(createResponse.status()).toBe(201);
  const user = await createResponse.json();
  
  // Verify in UI (realistic)
  await page.goto('/admin/users');
  await expect(page.getByText('Test User')).toBeVisible();
  await expect(page.getByText(user.email)).toBeVisible();
  
  // Cleanup via API
  await request.delete(`/api/users/${user.id}`);
});

UI Action, API Verification

test('submit form, verify via API', async ({ page, request }) => {
  // Perform action in UI
  await page.goto('/products/new');
  await page.getByLabel('Name').fill('New Product');
  await page.getByLabel('Price').fill('29.99');
  await page.getByRole('button', { name: 'Create' }).click();
  
  await expect(page.getByText('Product created')).toBeVisible();
  
  // Verify via API
  const response = await request.get('/api/products?name=New+Product');
  const products = await response.json();
  
  expect(products.length).toBe(1);
  expect(products[0].price).toBe(29.99);
});

Full CRUD Test Example

Here's a complete example testing a REST API:

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

test.describe('Users API', () => {
  let userId: string;
  
  test('POST - create user', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        name: 'John Doe',
        email: 'john.doe@example.com',
        role: 'user',
      },
    });
    
    expect(response.status()).toBe(201);
    
    const user = await response.json();
    expect(user).toMatchObject({
      name: 'John Doe',
      email: 'john.doe@example.com',
      role: 'user',
    });
    expect(user.id).toBeDefined();
    expect(user.createdAt).toBeDefined();
    
    userId = user.id;
  });
  
  test('GET - retrieve user', async ({ request }) => {
    const response = await request.get(`/api/users/${userId}`);
    
    expect(response.ok()).toBeTruthy();
    
    const user = await response.json();
    expect(user.id).toBe(userId);
    expect(user.name).toBe('John Doe');
  });
  
  test('GET - list all users', async ({ request }) => {
    const response = await request.get('/api/users');
    
    expect(response.ok()).toBeTruthy();
    
    const users = await response.json();
    expect(Array.isArray(users)).toBeTruthy();
    expect(users.some(u => u.id === userId)).toBeTruthy();
  });
  
  test('PUT - update user', async ({ request }) => {
    const response = await request.put(`/api/users/${userId}`, {
      data: {
        name: 'John Smith',
        email: 'john.smith@example.com',
        role: 'admin',
      },
    });
    
    expect(response.ok()).toBeTruthy();
    
    const user = await response.json();
    expect(user.name).toBe('John Smith');
    expect(user.role).toBe('admin');
  });
  
  test('PATCH - partial update', async ({ request }) => {
    const response = await request.patch(`/api/users/${userId}`, {
      data: {
        role: 'user',
      },
    });
    
    expect(response.ok()).toBeTruthy();
    
    const user = await response.json();
    expect(user.name).toBe('John Smith'); // Unchanged
    expect(user.role).toBe('user'); // Updated
  });
  
  test('DELETE - remove user', async ({ request }) => {
    const response = await request.delete(`/api/users/${userId}`);
    
    expect(response.status()).toBe(204);
    
    // Verify deletion
    const getResponse = await request.get(`/api/users/${userId}`);
    expect(getResponse.status()).toBe(404);
  });
});

Testing Error Responses

test.describe('API error handling', () => {
  test('404 for non-existent resource', async ({ request }) => {
    const response = await request.get('/api/users/non-existent-id');
    
    expect(response.status()).toBe(404);
    
    const error = await response.json();
    expect(error.message).toContain('not found');
  });
  
  test('400 for invalid data', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        // Missing required fields
        name: 'Test',
      },
    });
    
    expect(response.status()).toBe(400);
    
    const error = await response.json();
    expect(error.errors).toContainEqual(
      expect.objectContaining({ field: 'email' })
    );
  });
  
  test('401 for unauthorized access', async ({ request }) => {
    // Create context without auth
    const unauthRequest = await request.newContext();
    
    const response = await unauthRequest.get('/api/protected');
    
    expect(response.status()).toBe(401);
    
    await unauthRequest.dispose();
  });
  
  test('409 for duplicate resource', async ({ request }) => {
    // Create first user
    await request.post('/api/users', {
      data: { email: 'unique@example.com', name: 'First' },
    });
    
    // Try to create duplicate
    const response = await request.post('/api/users', {
      data: { email: 'unique@example.com', name: 'Second' },
    });
    
    expect(response.status()).toBe(409);
  });
});

API Test Fixtures

Create reusable API helpers:

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

type APIFixtures = {
  apiClient: APIRequestContext;
  createUser: (data: any) => Promise<any>;
  deleteUser: (id: string) => Promise<void>;
};

export const test = base.extend<APIFixtures>({
  apiClient: async ({ request }, use) => {
    await use(request);
  },
  
  createUser: async ({ request }, use) => {
    const createdUsers: string[] = [];
    
    const create = async (data: any) => {
      const response = await request.post('/api/users', { data });
      const user = await response.json();
      createdUsers.push(user.id);
      return user;
    };
    
    await use(create);
    
    // Cleanup: delete all created users
    for (const id of createdUsers) {
      await request.delete(`/api/users/${id}`);
    }
  },
  
  deleteUser: async ({ request }, use) => {
    await use(async (id: string) => {
      await request.delete(`/api/users/${id}`);
    });
  },
});

export { expect } from '@playwright/test';

Use in tests:

import { test, expect } from './fixtures';

test('user fixture auto-cleanup', async ({ createUser, page }) => {
  const user = await createUser({
    name: 'Test User',
    email: 'test@example.com',
  });
  
  // Use in UI test
  await page.goto(`/users/${user.id}`);
  await expect(page.getByText('Test User')).toBeVisible();
  
  // No cleanup needed - fixture handles it
});

What's Next?

You now have the power of API testing in your toolkit:

  • Use the request fixture for API calls
  • Make GET, POST, PUT, PATCH, DELETE requests
  • Assert status codes, bodies, and headers
  • Reuse authentication from browser tests
  • Combine API and UI tests for speed and realism
  • Test error responses and edge cases
  • Create reusable API fixtures

Your tests are working great locally. Time to scale up: run tests in parallel and across browsers. Let's go!