API testing with Playwright
Updated 2026-11-13 · 4 min read
Use APIRequestContext to test a local JSON service: create, read, reject invalid input, delete and verify cleanup. No browser required.
Build a small API check that verifies state, not only status codes. The complete script below starts its own in-memory ticket service, exercises it and closes it. It uses APIRequestContext only: no page, browser, browser fixture or browser download.
Verified on Linux with Bash, Node v24.22.2 and Playwright 1.65.0. The npm install requires network access but no account. All data is fake, and the server binds only to an OS-assigned loopback port.
1. Create a standalone project
lab=$(mktemp -d /tmp/qa-playwright-api-XXXXXX)
cd "$lab"
node --version
npm init -y
npm install --save-exact [email protected]
npm ls playwright
Save the following complete file as api-check.mjs:
// api-check.mjs
import http from 'node:http';
import assert from 'node:assert/strict';
import { request } from 'playwright';
let ticket = null;
const server = http.createServer(async (req, res) => {
const send = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(body === undefined ? undefined : JSON.stringify(body));
};
if (req.method === 'POST' && req.url === '/tickets') {
let raw = '';
for await (const chunk of req) raw += chunk;
let data;
try { data = JSON.parse(raw); }
catch { return send(400, { error: 'invalid JSON' }); }
if (typeof data?.title !== 'string' || !data.title.trim()) {
return send(422, { error: 'title required' });
}
ticket = { id: 1, title: data.title };
return send(201, ticket);
}
if (req.method === 'GET' && req.url === '/tickets/1' && ticket) {
return send(200, ticket);
}
if (req.method === 'DELETE' && req.url === '/tickets/1' && ticket) {
ticket = null;
return send(204);
}
send(404, { error: 'not found' });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let api;
try {
api = await request.newContext({
baseURL: `http://127.0.0.1:${server.address().port}`,
timeout: 5000,
failOnStatusCode: false,
});
const created = await api.post('/tickets', { data: { title: 'Fake OTP issue' } });
assert.equal(created.status(), 201);
assert.match(created.headers()['content-type'], /application\/json/);
assert.deepEqual(await created.json(), { id: 1, title: 'Fake OTP issue' });
const read = await api.get('/tickets/1');
assert.equal(read.status(), 200);
assert.deepEqual(await read.json(), { id: 1, title: 'Fake OTP issue' });
const invalid = await api.post('/tickets', { data: { title: '' } });
assert.equal(invalid.status(), 422);
assert.deepEqual(await invalid.json(), { error: 'title required' });
const unchanged = await api.get('/tickets/1');
assert.equal(unchanged.status(), 200);
assert.deepEqual(await unchanged.json(), { id: 1, title: 'Fake OTP issue' });
const malformed = await api.post('/tickets', {
headers: { 'Content-Type': 'application/json' }, data: Buffer.from('{'),
});
assert.equal(malformed.status(), 400);
assert.deepEqual(await malformed.json(), { error: 'invalid JSON' });
const removed = await api.delete('/tickets/1');
assert.equal(removed.status(), 204);
assert.equal(await removed.text(), '');
const missing = await api.get('/tickets/1');
assert.equal(missing.status(), 404);
assert.deepEqual(await missing.json(), { error: 'not found' });
console.log('PASS: create, read, invalid input, unchanged state, malformed JSON, delete, missing');
} finally {
try { if (api) await api.dispose(); }
finally { await new Promise(resolve => server.close(resolve)); }
}
2. Run the checks
node api-check.mjs
Expect one PASS line naming seven checked outcomes and exit code zero. An assertion failure makes Node exit unsuccessfully. A new run starts with empty state, so repeated runs do not depend on leftover tickets.
3. Understand the request context
request.newContext creates an independent API client. baseURL lets requests use relative paths, and the timeout bounds each request. Object data is serialised as JSON; a Buffer lets the malformed-input check send the raw broken payload without JSON serialisation.
failOnStatusCode: false lets the code inspect error responses. It does not make an error response a passing test: explicit assertions decide that. Check status before parsing JSON, and do not call .json() on a 204 response with an empty body. dispose() releases the context's retained response resources.
4. Check side effects and cleanup
The read after creation verifies the service can retrieve the ticket. The read after invalid input proves that particular rejection did not overwrite the valid ticket. Deletion checks both the empty response and a subsequent 404. Those observations are stronger than checking only 201 or 204.
The fixture stores one record in memory and overwrites it on another valid POST. It is deliberately not a production CRUD service: there is no authentication, persistence, concurrency or full schema validation. The lab teaches the client APIs and assertion pattern; it does not establish production coverage.
Explain it in an interview
“I create independent data, assert the response contract, read back the state and verify cleanup. For negative cases I check that rejection has not damaged the existing record. I dispose the request context even if an assertion fails.”
Follow-up: does an API test validate browser CORS or rendering? No. Those require separate browser checks. A common mistake is treating a green API run as UI coverage. This lab leaves no server running; the temporary project can be deleted when finished.
Quick answers
Do I need to install a browser?
No. This script creates a standalone APIRequestContext and never launches a browser.
Does request context cleanup delete server records?
No. dispose releases client resources. Server-side records require API cleanup or an isolated fixture, as used here.
Sources
Want a project you can show in interviews?
The portfolio project kit walks you through testing a deliberately buggy app with Selenium or Playwright and API tests, then explaining it. The app itself is free.
