API Testing with Playwright
Test your backend APIs directly without a browser
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
baseURLfrom 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
requestfixture 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!