Playwright API Testing: Test REST APIs Without a Browser
Most teams treat API testing and browser testing as separate concerns — different tools, different test suites, different CI jobs. Playwright's APIRequestContext lets you test REST APIs directly, using the same test runner, same fixtures, and same authentication setup as your UI tests. The result is a unified testing layer where API and browser tests work together rather than in silos.
Why Test APIs with Playwright?
The obvious choice for API testing is a dedicated tool like pytest, Rest Assured, or Supertest. These work well. But Playwright's API testing capabilities solve a specific problem: tests that need both API and browser interactions.
Consider a test that:
- Creates a user via API (fast, no UI flakiness)
- Verifies the user appears correctly in the UI (browser test)
- Updates user data via the UI
- Verifies the update is reflected in the API response
With separate tools, this test is awkward — you're shuttling authentication tokens and test data between two different frameworks. With Playwright, it's a single test with a single context.
Making API Requests
APIRequestContext is available as request in your test fixtures:
import { test, expect } from '@playwright/test';
test('get users list', async ({ request }) => {
const response = await request.get('https://api.example.com/users');
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toHaveProperty('users');
expect(Array.isArray(body.users)).toBe(true);
});
test('create a user', async ({ request }) => {
const response = await request.post('https://api.example.com/users', {
data: {
name: 'Alice Smith',
email: 'alice@example.com',
role: 'editor',
},
});
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.id).toBeTruthy();
expect(user.name).toBe('Alice Smith');
});The request fixture sends real HTTP requests — no browser, no DOM, just HTTP. Response parsing with .json(), .text(), or .body() works exactly as you'd expect.
Supported HTTP Methods
// GET with query parameters
const response = await request.get('/api/products', {
params: { category: 'electronics', limit: 20, sort: 'price_asc' },
});
// POST with JSON body
const response = await request.post('/api/orders', {
data: { productId: 'prod-123', quantity: 2 },
});
// PUT for full replacement
const response = await request.put('/api/users/456', {
data: { name: 'Updated Name', email: 'new@example.com', role: 'admin' },
});
// PATCH for partial update
const response = await request.patch('/api/users/456', {
data: { role: 'viewer' },
});
// DELETE
const response = await request.delete('/api/users/456');
expect(response.status()).toBe(204);
// HEAD — check existence without body
const response = await request.head('/api/resources/789');
expect(response.status()).toBe(200);Setting Headers and Auth Tokens
Pass headers per-request or configure defaults for all requests:
// Per-request headers
const response = await request.get('/api/data', {
headers: {
'Authorization': 'Bearer eyJhbGciOiJSUzI1NiJ9...',
'X-API-Version': '2024-01',
'Accept': 'application/json',
},
});
// Base URL and default headers in config
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'Accept': 'application/json',
'X-API-Client': 'playwright-tests',
},
},
});
// Now requests use the base URL and default headers automatically
const response = await request.get('/users'); // → https://api.example.com/usersSharing Auth Between API and Browser Tests
The most powerful feature of Playwright API testing is shared authentication context. When you log in via the browser, those session cookies are available to API requests in the same context — and vice versa.
test('login via API, verify in browser', async ({ page, request }) => {
// Login via API (faster than browser login UI)
const loginResponse = await request.post('/api/auth/login', {
data: { email: 'test@example.com', password: 'password123' },
});
expect(loginResponse.status()).toBe(200);
const { token } = await loginResponse.json();
// Set the token in the browser context
await page.goto('/');
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
// Now navigate to a protected page — browser is authenticated
await page.goto('/dashboard');
await expect(page.locator('[data-testid="user-greeting"]')).toBeVisible();
});
test('create data via API, verify in UI', async ({ page, request }) => {
// Create test data via API (no UI flakiness)
const createResponse = await request.post('/api/projects', {
data: { name: 'API-Created Project', description: 'Created in test setup' },
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
const project = await createResponse.json();
// Verify the project appears in the UI
await page.goto('/projects');
await expect(page.locator(`[data-testid="project-${project.id}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="project-${project.id}"] h3`))
.toHaveText('API-Created Project');
});Creating Standalone API Test Contexts
For pure API tests (no browser), create a request context directly without spinning up a browser:
import { test, expect, request as playwrightRequest } from '@playwright/test';
test('API performance — 100 concurrent requests', async () => {
const context = await playwrightRequest.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: { 'Authorization': 'Bearer test-token' },
});
const requests = Array.from({ length: 100 }, (_, i) =>
context.get(`/users/${i + 1}`)
);
const responses = await Promise.all(requests);
const allSuccessful = responses.every(r => r.status() === 200);
expect(allSuccessful).toBe(true);
await context.dispose();
});Standalone contexts are lighter than browser contexts — useful for high-volume API testing where you don't need browser overhead.
Asserting Response Shape
Playwright provides expect matchers for HTTP responses:
test('user API response structure', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.ok()).toBe(true); // 2xx status
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
const user = await response.json();
// Shape validation
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.stringMatching(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
createdAt: expect.any(String),
});
// Specific values
expect(user.id).toBe(1);
expect(user.role).toMatch(/^(admin|editor|viewer)$/);
});Testing Pagination
test('pagination returns correct pages', async ({ request }) => {
// First page
const page1 = await request.get('/api/articles', {
params: { page: 1, perPage: 10 },
});
const data1 = await page1.json();
expect(data1.articles).toHaveLength(10);
expect(data1.pagination.page).toBe(1);
expect(data1.pagination.totalPages).toBeGreaterThan(1);
// Second page
const page2 = await request.get('/api/articles', {
params: { page: 2, perPage: 10 },
});
const data2 = await page2.json();
// Pages should not overlap
const page1Ids = data1.articles.map((a: any) => a.id);
const page2Ids = data2.articles.map((a: any) => a.id);
const overlap = page1Ids.filter((id: number) => page2Ids.includes(id));
expect(overlap).toHaveLength(0);
});CRUD Test Lifecycle
test.describe('Project CRUD', () => {
let projectId: number;
test('create project', async ({ request }) => {
const response = await request.post('/api/projects', {
data: { name: 'CRUD Test Project' },
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(response.status()).toBe(201);
const project = await response.json();
projectId = project.id;
expect(projectId).toBeTruthy();
});
test('read project', async ({ request }) => {
const response = await request.get(`/api/projects/${projectId}`, {
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(response.status()).toBe(200);
const project = await response.json();
expect(project.name).toBe('CRUD Test Project');
});
test('update project', async ({ request }) => {
const response = await request.patch(`/api/projects/${projectId}`, {
data: { name: 'Updated CRUD Project' },
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(response.status()).toBe(200);
const project = await response.json();
expect(project.name).toBe('Updated CRUD Project');
});
test('delete project', async ({ request }) => {
const response = await request.delete(`/api/projects/${projectId}`, {
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(response.status()).toBe(204);
// Verify deletion
const getResponse = await request.get(`/api/projects/${projectId}`, {
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(getResponse.status()).toBe(404);
});
});Note: test.describe runs tests sequentially by default, which is correct here since each test depends on the previous one.
Testing Error Responses
test('returns 404 for nonexistent resource', async ({ request }) => {
const response = await request.get('/api/users/999999');
expect(response.status()).toBe(404);
const error = await response.json();
expect(error.error).toMatch(/not found/i);
});
test('returns 422 for invalid data', async ({ request }) => {
const response = await request.post('/api/users', {
data: { email: 'not-an-email', name: '' },
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(response.status()).toBe(422);
const error = await response.json();
expect(error.errors).toHaveProperty('email');
expect(error.errors).toHaveProperty('name');
});
test('returns 401 without auth', async ({ request }) => {
const response = await request.get('/api/private/data');
expect(response.status()).toBe(401);
});
test('returns 403 for insufficient permissions', async ({ request }) => {
// Authenticated as viewer, trying to access admin endpoint
const response = await request.delete('/api/admin/users/1', {
headers: { 'Authorization': `Bearer ${process.env.VIEWER_TOKEN}` },
});
expect(response.status()).toBe(403);
});Uploading Files
test('uploads avatar image', async ({ request }) => {
const fs = require('fs');
const imageBuffer = fs.readFileSync('tests/fixtures/avatar.png');
const response = await request.post('/api/users/me/avatar', {
multipart: {
file: {
name: 'avatar.png',
mimeType: 'image/png',
buffer: imageBuffer,
},
},
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
expect(response.status()).toBe(200);
const result = await response.json();
expect(result.avatarUrl).toMatch(/^https:\/\//);
});Using API Tests for Test Data Setup
The most practical use of Playwright API testing in a primarily UI test suite is test data setup and teardown:
// helpers/api.ts
import { APIRequestContext } from '@playwright/test';
export async function createTestProject(request: APIRequestContext, name: string) {
const response = await request.post('/api/projects', {
data: { name },
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
return await response.json();
}
export async function deleteProject(request: APIRequestContext, id: number) {
await request.delete(`/api/projects/${id}`, {
headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` },
});
}
// usage in UI test
test('project settings page', async ({ page, request }) => {
const project = await createTestProject(request, 'UI Test Project');
await page.goto(`/projects/${project.id}/settings`);
await expect(page.locator('h1')).toHaveText('UI Test Project');
// Cleanup
await deleteProject(request, project.id);
});This pattern gives you fast, reliable test data setup without the flakiness of UI-based data creation, and ensures cleanup happens even when tests are run in parallel.
Playwright's API testing capability turns what was previously a reason to maintain two separate test frameworks into a native feature of the same tool you're already using. Start with API setup helpers in your UI tests, then expand to dedicated API test files for endpoint coverage that doesn't need a browser at all.