Playwright API Testing with APIRequestContext (TypeScript)
Playwright is famous for browser automation, but it’s also one of the best API testing frameworks for TypeScript: fast parallel runs, rich assertions, fixtures, retries, HTML reports and trace files, all without opening a browser. Everything starts with APIRequestContext. In this lesson you’ll set up a Playwright API project from scratch, learn every request option you’ll actually use, and avoid the one baseURL mistake that silently breaks almost every beginner’s first suite.
request fixture in tests (request.get(), post(), put(), patch(), delete(), head(), fetch()), or create your own with request.newContext({ baseURL, extraHTTPHeaders, httpCredentials }) and dispose() it afterwards. Each call returns an APIResponse with status(), ok(), headers(), json(), text() and body(). Set baseURL with a trailing slash and write request paths without a leading slash.Why Playwright for API Testing?
- One framework for UI and API: create data through the API, verify it in the UI, share fixtures and reports.
- TypeScript first: autocompletion, type-safe clients and schemas.
- Fast: tests run in parallel workers by default; API tests take milliseconds.
- Batteries included: retries, tags, projects, HTML/JUnit reports, traces, CI examples.
Already know the Playwright basics? The Playwright with TypeScript tutorial covers installation and the test runner in depth; the shorter Playwright API testing intro is a good warm-up. This module goes deeper and builds a full framework step by step.
Project Setup (5 Minutes)
mkdir sqa-api-tests && cd sqa-api-tests
npm init -y
npm install -D @playwright/test typescript @types/node
# Browsers are NOT needed for pure API tests, so you can skip "npx playwright install"
Create playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
// Trailing slash matters: request paths are resolved relative to this URL.
baseURL: process.env.API_BASE_URL ?? 'https://sqatools.in/wp-json/sqa-api/v1/',
extraHTTPHeaders: { Accept: 'application/json' },
},
});
Every request in every test now starts from the practice API’s base URL and sends Accept: application/json. Switching to another environment is one environment variable: API_BASE_URL=https://qa.example.com/api/ npx playwright test.
The baseURL Trap (Read This Twice)
Playwright resolves request paths the same way a browser resolves links. We ran all three combinations against the live API:
| baseURL | Path in test | Actual URL called | Result |
|---|---|---|---|
.../sqa-api/v1/ | 'users/1' | https://sqatools.in/wp-json/sqa-api/v1/users/1 | 200 ✔ |
.../sqa-api/v1/ | '/users/1' | https://sqatools.in/users/1 | 404 ✘ |
.../sqa-api/v1 (no slash) | 'users/1' | https://sqatools.in/wp-json/sqa-api/users/1 | 404 ✘ |
/api/v1, /wp-json/...), end baseURL with / and write paths without a leading /. A leading slash means “from the domain root” and drops the prefix. If your tests mysteriously get 404s, print res.url() first.Your First API Tests
tests/request-basics.spec.ts:
import { test, expect, request } from '@playwright/test';
test.describe('APIRequestContext basics', () => {
test('GET with the built-in request fixture', async ({ request }) => {
const res = await request.get('users/1'); // resolved against baseURL
expect(res.status()).toBe(200);
expect(res.ok()).toBeTruthy();
expect(res.url()).toBe('https://sqatools.in/wp-json/sqa-api/v1/users/1');
expect(res.headers()['content-type']).toContain('application/json');
const body = await res.json();
expect(body.data.name).toBe('Asha Verma');
});
test('query parameters with params', async ({ request }) => {
const res = await request.get('users', { params: { role: 'admin', sort: 'age', order: 'desc' } });
await expect(res).toBeOK();
const { data } = await res.json();
expect(data.map((u: { id: number }) => u.id)).toEqual([7, 1]);
});
test('POST JSON, form and raw bodies', async ({ request }) => {
const json = await request.post('objects', { data: { name: 'json body', ttl: 30 } });
expect(json.status()).toBe(201);
const form = await request.post('echo', { form: { name: 'Asha', city: 'Pune' } });
expect((await form.json()).headers['content-type']).toContain('application/x-www-form-urlencoded');
const raw = await request.post('validate/user', {
headers: { 'Content-Type': 'application/json' },
data: '{"name": "Asha", ', // a string is sent as-is
});
expect(raw.status()).toBe(400);
});
test('a separate context with its own settings', async () => {
const api = await request.newContext({
baseURL: 'https://sqatools.in/wp-json/sqa-api/v1/',
extraHTTPHeaders: { 'X-API-Key': 'sqa-key-12345' },
timeout: 10_000,
});
const res = await api.get('apikey/profile');
expect(res.status()).toBe(200);
await api.dispose(); // always free the context
});
test('redirects, timeouts and failOnStatusCode', async ({ request }) => {
const noFollow = await request.get('redirect', { maxRedirects: 0 });
expect(noFollow.status()).toBe(302);
expect(noFollow.headers()['location']).toBeTruthy();
await expect(request.get('delay/5', { timeout: 2_000 })).rejects.toThrow(/Timeout/);
await expect(request.get('users/999', { failOnStatusCode: true })).rejects.toThrow(/404/);
});
});
npx playwright test tests/request-basics.spec.ts
npx playwright show-report # open the HTML report
All five tests pass against the live practice API in a few seconds.
Request Methods and Options
| Method | Use |
|---|---|
request.get(url, options) | GET |
request.post / put / patch / delete | Write methods |
request.head(url) | Headers only |
request.fetch(url, { method: 'OPTIONS' }) | Any method (OPTIONS, custom) |
| Option | Purpose | Example |
|---|---|---|
params | Query string (encoded for you) | { page: 2, per_page: 5 } |
headers | Per-request headers | { 'If-Match': '"v1"' } |
data | Body: object → JSON (sets Content-Type), string/Buffer → sent as-is | { name: 'A' } |
form | URL-encoded form body | { user: 'a', pass: 'b' } |
multipart | File uploads | { file: fs.createReadStream('a.pdf') } |
timeout | Per-request timeout in ms (0 = none) | 2_000 |
maxRedirects | 0 to assert the redirect itself | 0 |
failOnStatusCode | Throw on non-2xx/3xx | Handy for setup steps |
maxRetries | Retry on network errors (not on HTTP errors) | 2 |
ignoreHTTPSErrors | Self-signed test certificates | Never for production |
The APIResponse Object
| Method | Returns |
|---|---|
status() / statusText() | 200 / “OK” |
ok() | true for 200 to 299 |
headers() | Object with lower-case header names |
headersArray() | Array of {name, value}, needed for repeated headers like Set-Cookie |
json() / text() / body() | Parsed JSON / string / Buffer |
url() | Final URL (after redirects) |
await expect(res).toBeOK() when you require success: on failure it prints the status and response body, making failures easy to read.request Fixture vs newContext vs page.request
| Use when | |
|---|---|
request fixture | Most tests; uses config use settings (baseURL, headers); created and disposed for you |
request.newContext() (imported) or playwright.request.newContext() (fixture) | Different settings: another base URL, auth headers per role, separate cookies; call dispose() |
page.request / context.request | Hybrid UI + API tests: shares cookies with the browser context, so an API login logs the page in too |
page.request.post('/api/orders', ...) in two lines, then opens the order page and checks the UI. The test runs 10x faster and fails only for real UI problems. You’ll build this in Project 5.Common Beginner Mistakes
- Leading slash in paths with a prefixed baseURL (404s).
- Forgetting
awaitbeforerequest.get()orres.json(). - Reading the body twice from different methods is fine, but don’t forget
awaiton each. - Not disposing contexts you created (memory leaks in long runs).
- Using
failOnStatusCode: trueglobally, then wondering why negative tests throw. - Hardcoding URLs in tests instead of
baseURL.
Interview Questions: Playwright APIRequestContext
How do you do API testing in Playwright?
Use the request fixture (an APIRequestContext) inside tests to send HTTP requests with get, post, put, patch, delete or fetch, then assert on the APIResponse: status, headers and parsed JSON. Configure baseURL and shared headers in playwright.config.ts.
What is APIRequestContext?
Playwright’s API for sending HTTP requests from Node.js. It manages cookies, base URL, default headers, credentials and timeouts. It’s available as the request fixture, via request.newContext(), and as page.request bound to a browser context.
Does Playwright need a browser for API tests?
No. APIRequestContext sends requests directly from Node.js, so pure API tests don’t launch browsers and don’t need browser binaries installed.
How do you send query parameters and JSON bodies?
Pass params for the query string and data for the body. When data is an object, Playwright serialises it as JSON and sets Content-Type: application/json; strings and Buffers are sent as-is. Use form for URL-encoded and multipart for files.
Why does a request with a leading slash return 404 when baseURL has a path?
Paths are resolved like browser URLs: a leading slash means the domain root, so the base path is dropped. End baseURL with a slash and use relative paths without a leading slash.
Does Playwright throw an error for 4xx or 5xx responses?
Not by default; the response is returned so you can assert on it. Set failOnStatusCode: true to throw for non-2xx/3xx, or use expect(response).toBeOK().
What is the difference between request and page.request?
The request fixture is an isolated API context. page.request belongs to the page’s browser context and shares its cookie storage, so API calls and the UI share login state.
How do you test a redirect without following it?
Pass maxRedirects: 0 and assert the 3xx status and the Location header.
How do you set headers for all requests?
Use extraHTTPHeaders in the config’s use section or in request.newContext(); add per-request headers with the headers option.
Why should you dispose an APIRequestContext?
Contexts you create with newContext hold resources such as connections and stored responses; dispose() releases them. Fixture contexts are disposed automatically.