When a UI test deletes a resource and checks the UI, it only verifies the frontend updated: the API call might have failed silently while the UI showed an optimistic success state. Asserting the DELETE at the API level afterward catches that. APIRequestContext is Playwright's built-in HTTP client, available through the request fixture in the same @playwright/test package you already have installed. This guide covers the full workflow: GET and POST with assertions, Bearer token auth, seeding test data via API before UI tests run, and the difference between request (isolated cookie jar) and page.request (shares browser session cookies).
What APIRequestContext is and when to use it
APIRequestContext is Playwright's built-in HTTP client. It lets you send GET, POST, PUT, PATCH, DELETE requests, inspect responses, handle headers and cookies, and assert on status codes and response bodies, all from inside a .spec.ts file.
It is not a replacement for UI tests. It serves a different purpose. A UI test drives the browser: it clicks buttons, fills forms, waits for elements. An APIRequestContext test sends HTTP requests directly to the server, skipping the browser entirely. That makes it faster, more reliable, and better suited for testing the backend layer.
When should you reach for APIRequestContext instead of a UI test?
- You're testing data validation. Does the API reject a missing required field?
- You're testing authentication. Does a 401 come back when no token is present?
- You're testing business logic that lives in the server, not the UI.
- You want to seed test data before a UI test without going through the UI form.
- You want to verify a backend side-effect after a UI action.
When should you stick with UI tests? When you're testing what the user actually sees and interacts with: rendering, navigation, form behavior, visual feedback. Both layers belong in a complete test suite. The mistake is using one where the other is clearly better.
The request fixture
Playwright exposes APIRequestContext through the built-in request fixture. You use it exactly the same way you use page: declare it in the test function signature and Playwright handles the setup.
import { test, expect } from '@playwright/test';
test('GET /api/items returns 200', async ({ request }) => {
const response = await request.get('https://lab.becomeqa.com/api/items');
expect(response.status()).toBe(200);
});No browser window opens. No DOM renders. The test runner sends an HTTP request, receives a response, and your assertions run. The whole thing completes in under 100 milliseconds on a typical connection.
The request fixture creates an isolated APIRequestContext for each test. It has its own cookie jar, its own headers, and no connection to any browser context. That isolation is intentional: your API tests are independent of whatever the browser is doing.
Making GET requests and asserting responses
A GET test has three parts: send the request, check the status, check the body.
import { test, expect } from '@playwright/test';
test('GET /api/items returns a valid list', async ({ request }) => {
const response = await request.get('https://lab.becomeqa.com/api/items');
// Status check
expect(response.status()).toBe(200);
expect(response.ok()).toBeTruthy(); // shorthand: true for any 2xx
// Parse the JSON body
const items = await response.json();
// Shape checks
expect(Array.isArray(items)).toBe(true);
expect(items.length).toBeGreaterThan(0);
// Check a single item's properties
const first = items[0];
expect(first).toHaveProperty('id');
expect(first).toHaveProperty('destination');
expect(first).toHaveProperty('status');
});response.ok() returns true for any status code in the 200–299 range. Use it when you only need to confirm success and don't care about the exact code. Use response.status() when the specific code matters: 200 vs 201 vs 204 each mean different things.
You can also read the body as text or as a buffer:
const text = await response.text();
const buffer = await response.body(); // BufferTo assert on response headers:
test('response includes JSON content-type', async ({ request }) => {
const response = await request.get('https://lab.becomeqa.com/api/items');
const headers = response.headers();
expect(headers['content-type']).toContain('application/json');
});POST requests with a JSON body
POST requests send data to the server. Pass the payload via the data option and Playwright automatically serializes it to JSON and sets Content-Type: application/json.
test('POST /api/items creates a resource', async ({ request }) => {
const response = await request.post('https://lab.becomeqa.com/api/items', {
data: {
destination: 'Kyoto',
status: 'planned',
notes: 'Visit Arashiyama bamboo grove'
}
});
// A well-designed API returns 201 Created for new resources
expect(response.status()).toBe(201);
const created = await response.json();
expect(created).toHaveProperty('id');
expect(created.destination).toBe('Kyoto');
expect(created.status).toBe('planned');
});Store the returned id when you need to clean up or chain requests:
test('create and then delete an item', async ({ request }) => {
const createRes = await request.post('https://lab.becomeqa.com/api/items', {
data: { destination: 'Tbilisi', status: 'planned' }
});
expect(createRes.status()).toBe(201);
const { id } = await createRes.json();
const deleteRes = await request.delete(`https://lab.becomeqa.com/api/items/${id}`);
expect(deleteRes.status()).toBe(204);
// Confirm it's gone
const getRes = await request.get(`https://lab.becomeqa.com/api/items/${id}`);
expect(getRes.status()).toBe(404);
});For form-encoded data, swap data for form:
const response = await request.post('https://lab.becomeqa.com/api/login', {
form: {
username: 'admin@becomeqa.com',
password: 'testpass123'
}
});Authentication: Bearer tokens and custom headers
Most real APIs are authenticated. The two most common patterns are Bearer tokens and API keys passed in headers.
Bearer token: login first, then use the token.test('authenticated request with Bearer token', async ({ request }) => {
// Step 1: obtain a token
const loginRes = await request.post('https://lab.becomeqa.com/api/auth/login', {
data: {
username: 'admin@becomeqa.com',
password: 'testpass123'
}
});
expect(loginRes.status()).toBe(200);
const { token } = await loginRes.json();
// Step 2: use it on subsequent requests
const itemsRes = await request.get('https://lab.becomeqa.com/api/items', {
headers: {
Authorization: `Bearer ${token}`
}
});
expect(itemsRes.status()).toBe(200);
});When the same token is used across many tests, move the login step into a beforeAll block and share the token across the suite:
import { test, expect } from '@playwright/test';
let authToken: string;
test.beforeAll(async ({ request }) => {
const res = await request.post('https://lab.becomeqa.com/api/auth/login', {
data: {
username: 'admin@becomeqa.com',
password: 'testpass123'
}
});
const body = await res.json();
authToken = body.token;
});
test('GET items as authenticated user', async ({ request }) => {
const response = await request.get('https://lab.becomeqa.com/api/items', {
headers: { Authorization: `Bearer ${authToken}` }
});
expect(response.status()).toBe(200);
});playwright.config.ts.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://lab.becomeqa.com',
extraHTTPHeaders: {
'X-Api-Key': process.env.API_KEY ?? ''
}
}
});Every request the request fixture sends will include that header automatically. No need to repeat it in each test.
.env file locally (with dotenv) and GitHub Actions secrets in CI. Playwright's process.env access works the same in both environments.playwright.request.newContext() for standalone API tests
The request fixture is convenient inside tests, but sometimes you need an APIRequestContext outside the test runner: in a global setup file, in a utility script, or when you want a context with its own configuration that's separate from the default.
playwright.request.newContext() creates a standalone context you control explicitly:
// global-setup.ts
import { chromium, request } from '@playwright/test';
async function globalSetup() {
// Create a standalone API context
const apiContext = await request.newContext({
baseURL: 'https://lab.becomeqa.com',
extraHTTPHeaders: {
'Content-Type': 'application/json'
}
});
// Seed test data before any test runs
await apiContext.post('/api/items', {
data: { destination: 'Lisbon', status: 'planned' }
});
// Always dispose when done
await apiContext.dispose();
}
export default globalSetup;You reference the global setup file in your config:
// playwright.config.ts
export default defineConfig({
globalSetup: './global-setup.ts',
use: {
baseURL: 'https://lab.becomeqa.com'
}
});newContext() accepts the same options as the use config block: baseURL, extraHTTPHeaders, httpCredentials, ignoreHTTPSErrors. Call dispose() when the context is no longer needed. It closes open connections and clears cookies.
playwright.request.newContext() and the request fixture both create APIRequestContext instances. The difference is lifecycle: the fixture is automatically created and disposed per test. newContext() gives you manual control, useful for global setup, teardown scripts, or contexts that span multiple tests.Combining API setup with UI verification
This is where APIRequestContext pays its biggest dividend. The slowest, most fragile part of a UI test is usually the setup: filling forms, waiting for state, navigating through screens just to get to the scenario you want to test. Replace that with an API call.
test('item created via API appears in the UI list', async ({ page, request }) => {
// Fast, reliable setup. No browser involved.
const createRes = await request.post('https://lab.becomeqa.com/api/items', {
data: { destination: 'Porto', status: 'planned' }
});
expect(createRes.status()).toBe(201);
const { id } = await createRes.json();
// Now test what matters: does the UI display it correctly?
await page.goto('https://lab.becomeqa.com/items');
await expect(page.getByText('Porto')).toBeVisible();
await expect(page.getByTestId(`item-${id}`)).toBeVisible();
// Cleanup via API. Also faster than clicking through a UI delete flow.
await request.delete(`https://lab.becomeqa.com/api/items/${id}`);
});test('deleting an item via UI removes it from the database', async ({ page, request }) => {
// Create via API
const createRes = await request.post('https://lab.becomeqa.com/api/items', {
data: { destination: 'Valletta', status: 'planned' }
});
const { id } = await createRes.json();
// Delete through the UI. This is what we're actually testing.
await page.goto('https://lab.becomeqa.com/items');
await page.getByTestId(`item-${id}`).getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
// Verify the backend state, not just the UI state
const checkRes = await request.get(`https://lab.becomeqa.com/api/items/${id}`);
expect(checkRes.status()).toBe(404);
});This second pattern is underused. When a user deletes something, the UI might update optimistically and look correct even if the actual API call failed. Asserting at the API level means you catch that failure.
Reusable API helpers and fixtures
Repeating request.post('/api/auth/login', ...) in every test file is noise. Build a small helper class and expose it through a custom fixture.
First, the helper class:
// lib/api-client.ts
import { APIRequestContext } from '@playwright/test';
export class ApiClient {
constructor(private request: APIRequestContext) {}
async login(username: string, password: string): Promise<string> {
const res = await this.request.post('/api/auth/login', {
data: { username, password }
});
const { token } = await res.json();
return token;
}
async createItem(data: { destination: string; status: string; notes?: string }) {
const res = await this.request.post('/api/items', { data });
expect(res.status()).toBe(201);
return res.json();
}
async deleteItem(id: string) {
await this.request.delete(`/api/items/${id}`);
}
async getItem(id: string) {
return this.request.get(`/api/items/${id}`);
}
}Then expose it through a custom fixture:
// fixtures.ts
import { test as base } from '@playwright/test';
import { ApiClient } from './lib/api-client';
type Fixtures = {
api: ApiClient;
};
export const test = base.extend<Fixtures>({
api: async ({ request }, use) => {
const client = new ApiClient(request);
await use(client);
}
});
export { expect } from '@playwright/test';Tests become much more readable:
import { test, expect } from './fixtures';
test('create and verify item', async ({ api, page }) => {
const item = await api.createItem({ destination: 'Riga', status: 'planned' });
await page.goto('https://lab.becomeqa.com/items');
await expect(page.getByText('Riga')).toBeVisible();
await api.deleteItem(item.id);
});The helper encapsulates request logic. The fixture manages the lifecycle. The test focuses on the scenario. Each layer has one job.
expect calls out of the ApiClient class itself, except for mandatory preconditions like checking a 201 status after a create. Assertions in helpers make failures harder to trace because the stack points to the helper, not the test.request vs page.request: what's the difference
Both are APIRequestContext instances. The distinction is how they handle cookies and session state.
request, the fixture, is an isolated context. It has its own cookie jar, separate from any browser. It does not share state with page. When you log in via request, the browser has no idea.
page.request is tied to the browser context that page belongs to. It shares cookies with the page. If the user logs in through the browser, page.request carries those cookies. If page.request sets a cookie, the browser sees it.
test('difference between request and page.request', async ({ page, request }) => {
// Log in through the browser
await page.goto('https://lab.becomeqa.com/login');
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Login' }).click();
// page.request carries the auth cookie, returns 200
const withCookies = await page.request.get('https://lab.becomeqa.com/api/items');
expect(withCookies.status()).toBe(200);
// request fixture has no cookie, returns 401
const withoutCookies = await request.get('https://lab.becomeqa.com/api/items');
expect(withoutCookies.status()).toBe(401);
});Which one to use? If your test involves both a browser session and API calls that should use the same auth, use page.request. If you're writing pure API tests with no browser, use the request fixture. If you need a fully independent context with custom headers or base URL, use playwright.request.newContext().
FAQ
Do I still need Postman?Postman is a good exploration tool. When you first encounter an API and don't know its shape, fire up Postman, poke around, read the responses, figure out what you need. Once you know what you're testing, write it in Playwright. You get version control, CI integration, and the ability to combine API and UI assertions in the same test, none of which Postman gives you.
Can I useAPIRequestContext to test GraphQL?
Yes. GraphQL over HTTP is a POST request to a single endpoint with a JSON body containing query and optionally variables. The data option handles it directly:
const response = await request.post('https://lab.becomeqa.com/graphql', {
data: {
query: `
query GetItem($id: ID!) {
item(id: $id) {
id
destination
status
}
}
`,
variables: { id: '123' }
}
});
const { data } = await response.json();
expect(data.item.destination).toBe('Kyoto');APIRequestContext follow redirects automatically?
Yes, by default it follows up to 20 redirects. To disable redirect following and inspect the redirect response directly, pass maxRedirects: 0 in the request options.
Set a custom timeout in the request options or increase timeout in your playwright.config.ts for the API project. For rate-limited APIs in testing, consider seeding data in globalSetup once rather than creating it fresh in every test.
response.json() and response.text()?
response.json() parses the body and returns a JavaScript object. It throws if the body is not valid JSON. response.text() returns the raw string. Use text() for debugging or when the endpoint returns a non-JSON format like plain text or XML.
→ See also: Playwright Fixtures Explained: From Built-in to Custom | Global Setup and Teardown in Playwright | API Testing with Playwright: Beyond the UI | Advanced API Testing with Playwright: Patterns for Real Projects | Authentication in API Tests: API Keys, Bearer Tokens, OAuth2, JWT