API Testing with Playwright
Learn how to use Playwright's APIRequestContext for backend testing. Seed test data via API before UI tests, validate responses, handle authentication headers, and decide when API tests beat E2E.
This post is part of the Playwright Essentials series. Part 4 covered auto-waiting and killing flakes. This final post explores using Playwright for API testing — a powerful pattern for faster, more reliable tests.
Why API Testing with Playwright?
Most E2E testing frameworks focus exclusively on browser automation. Playwright includes a full-featured HTTP client: APIRequestContext. This unlocks two powerful patterns:
1. Seeding Data for UI Tests
Instead of clicking through the UI to create test data, call the API directly:
test('user can view their order', async ({ page, request }) => {
// Create order via API (fast)
const response = await request.post('/api/orders', {
data: { product: 'Laptop', quantity: 1 }
});
const order = await response.json();
// Test the UI (what the user sees)
await page.goto(`/orders/${order.id}`);
await expect(page.getByText('Laptop')).toBeVisible();
});
This is 10x faster than creating the order through the UI. It’s also more reliable — you’re not depending on the checkout flow to work just to set up your test.
2. Pure API Testing
You can write API-only tests in Playwright without touching the browser:
test('API returns correct order status', async ({ request }) => {
const response = await request.get('/api/orders/123');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const order = await response.json();
expect(order.status).toBe('completed');
});
This runs in milliseconds compared to seconds for browser tests. Use this for testing backend logic, validating API contracts, and catching regressions early.
The request Fixture
Playwright provides the request fixture in every test. It’s an instance of APIRequestContext pre-configured with:
- Base URL from
playwright.config.ts - Default headers
- Cookie/session handling
- Automatic JSON parsing
Basic GET Request
test('fetch user profile', async ({ request }) => {
const response = await request.get('/api/users/me');
expect(response.ok()).toBeTruthy();
const user = await response.json();
expect(user.email).toBe('user@example.com');
});
POST Request with JSON Body
test('create new order', async ({ request }) => {
const response = await request.post('/api/orders', {
data: {
product: 'Wireless Mouse',
quantity: 2,
shippingAddress: '123 Main St'
}
});
expect(response.status()).toBe(201);
const order = await response.json();
expect(order.id).toBeDefined();
expect(order.product).toBe('Wireless Mouse');
});
PUT Request (Update)
test('update order status', async ({ request }) => {
const response = await request.put('/api/orders/123', {
data: { status: 'shipped' }
});
expect(response.ok()).toBeTruthy();
const updated = await response.json();
expect(updated.status).toBe('shipped');
});
DELETE Request
test('delete order', async ({ request }) => {
const response = await request.delete('/api/orders/123');
expect(response.status()).toBe(204); // No Content
});
Handling Authentication
Most APIs require authentication. Playwright provides several ways to handle this.
Option 1: Pass Headers Directly
test('authenticated request', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: {
'Authorization': 'Bearer abc123token'
}
});
expect(response.ok()).toBeTruthy();
});
Option 2: Set Default Headers in Config
// playwright.config.ts
export default defineConfig({
use: {
extraHTTPHeaders: {
'Authorization': 'Bearer abc123token',
},
},
});
Now every request includes the auth header automatically.
Option 3: Use a Fixture for Auth Setup
// fixtures/api.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedRequest: APIRequestContext;
};
export const test = base.extend<AuthFixtures>({
authenticatedRequest: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
extraHTTPHeaders: {
'Authorization': 'Bearer abc123token',
},
});
await use(context);
await context.dispose();
},
});
Option 4: Login via API Before Tests
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: 'user@example.com', password: 'password123' }
});
const { token } = await response.json();
process.env.AUTH_TOKEN = token;
});
test('use auth token', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: {
'Authorization': `Bearer ${process.env.AUTH_TOKEN}`
}
});
expect(response.ok()).toBeTruthy();
});
:::tip[Best Practice: Fixtures for Auth] Use fixtures (Option 3) for reusable auth patterns. It keeps auth logic out of individual tests and makes tests more readable. :::
Seeding Test Data via API
The killer pattern: create data via API, test the UI.
Example: Test Order Details Page
test('user can view order details', async ({ page, request }) => {
// Setup: Create order via API (fast)
const createResponse = await request.post('/api/orders', {
data: {
product: 'Mechanical Keyboard',
quantity: 1,
price: 89.99
}
});
const order = await createResponse.json();
// Test: Navigate to order page (UI test)
await page.goto(`/orders/${order.id}`);
// Assert: Check UI displays correct data
await expect(page.getByRole('heading')).toContainText(`Order #${order.id}`);
await expect(page.getByText('Mechanical Keyboard')).toBeVisible();
await expect(page.getByText('$89.99')).toBeVisible();
// Teardown: Delete order via API (clean)
await request.delete(`/api/orders/${order.id}`);
});
Why this is better than creating the order through the UI:
- Faster — API call takes 100ms, UI flow takes 5 seconds
- More reliable — doesn’t depend on checkout flow working
- Focused — tests the order details page, not the entire checkout process
Validating API Responses
Playwright provides helpers for common assertions:
Status Code Assertions
expect(response.ok()).toBeTruthy(); // Status 200-299
expect(response.status()).toBe(201);
expect(response.status()).toBeGreaterThanOrEqual(200);
expect(response.status()).toBeLessThan(300);
JSON Schema Validation
test('response has correct structure', async ({ request }) => {
const response = await request.get('/api/orders/123');
const order = await response.json();
// Type-safe assertions
expect(order).toHaveProperty('id');
expect(order).toHaveProperty('status');
expect(order).toHaveProperty('createdAt');
expect(typeof order.id).toBe('string');
expect(typeof order.status).toBe('string');
expect(order.items).toBeInstanceOf(Array);
});
For stricter schema validation, integrate a JSON schema validator:
import Ajv from 'ajv';
const ajv = new Ajv();
const orderSchema = {
type: 'object',
properties: {
id: { type: 'string' },
status: { type: 'string', enum: ['pending', 'completed', 'cancelled'] },
items: { type: 'array' }
},
required: ['id', 'status', 'items']
};
test('response matches schema', async ({ request }) => {
const response = await request.get('/api/orders/123');
const order = await response.json();
const validate = ajv.compile(orderSchema);
expect(validate(order)).toBe(true);
});
Header Assertions
test('response has correct headers', async ({ request }) => {
const response = await request.get('/api/orders');
expect(response.headers()['content-type']).toContain('application/json');
expect(response.headers()['cache-control']).toBe('no-cache');
});
Testing Error Scenarios
API tests excel at testing error states:
Test 404 Not Found
test('returns 404 for non-existent order', async ({ request }) => {
const response = await request.get('/api/orders/99999');
expect(response.status()).toBe(404);
const error = await response.json();
expect(error.message).toContain('Order not found');
});
Test 400 Bad Request
test('returns 400 for invalid data', async ({ request }) => {
const response = await request.post('/api/orders', {
data: {
product: '', // Invalid: empty product
quantity: -1 // Invalid: negative quantity
}
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.errors).toContain('Product name is required');
expect(error.errors).toContain('Quantity must be positive');
});
Test 401 Unauthorized
test('returns 401 without auth token', async ({ request }) => {
const response = await request.get('/api/orders');
expect(response.status()).toBe(401);
});
Test 500 Server Error
test('handles server errors gracefully', async ({ request }) => {
// Trigger server error (e.g., by sending malformed data)
const response = await request.post('/api/orders', {
data: { invalid: 'payload' }
});
if (response.status() === 500) {
const error = await response.json();
expect(error.message).toBeDefined();
}
});
When to Use API Tests vs E2E Tests
| Scenario | API Test | E2E Test |
|---|---|---|
| Backend logic | ✅ Fast, isolated | ❌ Slow, fragile |
| Data validation | ✅ Direct, precise | ⚠️ Indirect (check UI) |
| Error handling | ✅ Easy to trigger edge cases | ❌ Hard to simulate |
| Auth flows | ✅ Test token generation/validation | ⚠️ Test login UI + token usage |
| User journeys | ❌ Can’t test UI | ✅ Full user experience |
| Visual regression | ❌ No browser | ✅ Screenshots, visual diffs |
| Accessibility | ❌ No DOM | ✅ ARIA roles, screen reader |
General rule: Test logic at the lowest reliable level. If backend logic can be tested via API, do that. Reserve E2E tests for user-facing journeys that require the browser.
Combining API and UI Tests
The most powerful pattern: use both.
Pattern: API Setup, UI Test, API Teardown
test('user can add item to cart', async ({ page, request }) => {
// Setup: Create user and product via API
const userResponse = await request.post('/api/users', {
data: { email: 'test@example.com', password: 'Test1234!' }
});
const user = await userResponse.json();
const productResponse = await request.post('/api/products', {
data: { name: 'Laptop', price: 999 }
});
const product = await productResponse.json();
// Test: Use UI to add product to cart
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill('Test1234!');
await page.getByRole('button', { name: 'Log in' }).click();
await page.goto(`/products/${product.id}`);
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByText('Item added to cart')).toBeVisible();
// Verify: Check cart via API
const cartResponse = await request.get('/api/cart', {
headers: { 'Authorization': `Bearer ${user.token}` }
});
const cart = await cartResponse.json();
expect(cart.items).toHaveLength(1);
expect(cart.items[0].productId).toBe(product.id);
// Teardown: Clean up via API
await request.delete(`/api/users/${user.id}`);
await request.delete(`/api/products/${product.id}`);
});
This pattern:
- Setup — Fast, reliable (API)
- Test — Focuses on user interaction (UI)
- Verification — Checks backend state (API)
- Teardown — Fast cleanup (API)
Playwright vs Postman/Newman
| Feature | Playwright | Postman/Newman |
|---|---|---|
| Browser integration | ✅ Seamless (same tool) | ❌ Separate tool |
| TypeScript/JavaScript | ✅ Native | ⚠️ Limited scripting |
| CI/CD integration | ✅ Same pipeline as E2E | ⚠️ Separate pipeline |
| API-only tests | ✅ Full support | ✅ Designed for this |
| Collection management | ⚠️ Code-based (no GUI) | ✅ GUI collection editor |
| Learning curve | ⚠️ Requires JS/TS | ✅ Low (GUI-based) |
Use Playwright for API tests if: You’re already using Playwright for E2E tests. Keeping API and UI tests in the same framework simplifies CI/CD and reduces tool sprawl.
Use Postman if: You need a GUI for manual API exploration, or your team prefers visual collection management.
Real-World Example: E-Commerce Checkout
Let’s test a full checkout flow using API setup + UI test:
test('complete checkout flow', async ({ page, request }) => {
// 1. Setup: Create user, product, add to cart (all via API)
const user = await request.post('/api/users', {
data: { email: 'buyer@example.com', password: 'Secure123!' }
}).then(r => r.json());
const product = await request.post('/api/products', {
data: { name: 'Headphones', price: 79.99 }
}).then(r => r.json());
await request.post('/api/cart/add', {
headers: { 'Authorization': `Bearer ${user.token}` },
data: { productId: product.id, quantity: 1 }
});
// 2. Test: UI checkout flow
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill('Secure123!');
await page.getByRole('button', { name: 'Log in' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByText('Headphones')).toBeVisible();
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4111111111111111');
await page.getByLabel('Expiry').fill('12/25');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
// 3. Verify: Check order created via API
const orders = await request.get('/api/orders', {
headers: { 'Authorization': `Bearer ${user.token}` }
}).then(r => r.json());
expect(orders).toHaveLength(1);
expect(orders[0].status).toBe('completed');
expect(orders[0].total).toBe(79.99);
// 4. Teardown
await request.delete(`/api/users/${user.id}`);
await request.delete(`/api/products/${product.id}`);
});
This single test validates:
- Backend order creation (API)
- UI checkout flow (E2E)
- Payment processing (UI + API)
- Data integrity (API verification)
Conclusion
Playwright’s API testing capabilities turn it into a complete testing framework. You no longer need separate tools for API and UI testing.
Key takeaways:
- Use API calls to seed test data — 10x faster than UI setup
- Write pure API tests for backend logic — milliseconds instead of seconds
- Combine API and UI in a single test — setup via API, test via UI, verify via API
- Test error states easily — API tests excel at edge cases
The request fixture is one of Playwright’s most underused features. Master it, and your tests become faster, more reliable, and easier to maintain.
Action for this week: Find one E2E test that spends most of its time setting up test data through the UI (creating users, products, orders). Refactor it to use API setup. Measure before/after execution time. You’ll likely see a 5-10x speedup.
This concludes the Playwright Essentials series. You now have the foundation to build fast, reliable, maintainable E2E test suites. The rest is practice.
Previous: Part 4 — Auto-Waiting, Assertions, and Killing Flakes