17API Testing with Playwright
What you will master here
- REST principles (resources, verbs, statelessness)
- HTTP status codes — the full ladder, and 401 vs 403
- Playwright's
request/APIRequestContext - GET / POST / PUT / PATCH / DELETE with headers, auth, body
- Asserting status, JSON body, response time
- When to API-test vs UI-test
- Hybrid: API setup + UI verification
17.1 REST in one screen
REST = Representational State Transfer. The contract:
- Resources have URLs:
/users/42,/orders - HTTP verbs express the action: GET (read), POST (create), PUT (replace), PATCH (partial update), DELETE (remove)
- Statelessness — every request carries enough info to be processed; no server-side session memory between calls (auth via token in each request)
- Representations — usually JSON, sometimes XML, sometimes Protobuf
17.2 HTTP status codes (the ladder)
| Class | Code | Meaning | SDET hit-list |
|---|---|---|---|
| 2xx Success | 200 OK | Generic success | GET, PUT response |
| 201 Created | New resource created | POST response with new id | |
| 204 No Content | Success, no body | DELETE response | |
| 3xx Redirect | 301 Moved Permanently | URL changed forever | SEO / domain switch |
| 302 Found | Temporary redirect | Login → dashboard | |
| 4xx Client Error | 400 Bad Request | Malformed payload, validation failed | Bad JSON, missing fields |
| 401 Unauthorized | Authentication missing or invalid | No token / expired token | |
| 403 Forbidden | Authenticated, not allowed | User trying admin endpoint | |
| 404 Not Found | Resource doesn't exist | Bad id | |
| 409 Conflict | State conflict | Duplicate email signup | |
| 429 Too Many Requests | Rate limited | Brute-force protection | |
| 5xx Server Error | 500 Internal Server Error | Unhandled exception on server | The bug you file |
| 502 Bad Gateway | Upstream service replied badly | Microservice chain failure | |
| 503 Service Unavailable | Temporarily down / overloaded | Maintenance / autoscale |
401 vs 403 — must-know401 = "I don't know who you are" (no/invalid credentials). 403 = "I know who you are, you can't do this". An expired token is 401. A user hitting
/admin/delete is 403.17.3 Playwright's request fixture
Playwright ships an HTTP client called APIRequestContext, available as the built-in request fixture or via playwright.request.newContext(). It is async, returns Playwright Response objects you can assert on.
import { test, expect } from '@playwright/test';
test('GET /users returns a list', async ({ request }) => {
const res = await request.get('https://api.example.com/users');
expect(res.status()).toBe(200);
expect(res.ok()).toBeTruthy(); // 2xx
const json = await res.json();
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBeGreaterThan(0);
expect(json[0]).toHaveProperty('email');
});
17.4 GET / POST / PUT / PATCH / DELETE
// GET with query
const res = await request.get('/api/users', { params: { role: 'admin', limit: 10 } });
// POST JSON
const create = await request.post('/api/users', {
data: { name: 'Alice', email: 'alice@test.com' },
headers: { 'x-test-mode': '1' },
});
expect(create.status()).toBe(201);
const user = await create.json();
// PUT — replace
await request.put(`/api/users/${user.id}`, {
data: { name: 'Alice Smith', email: 'alice@test.com' },
});
// PATCH — partial update
await request.patch(`/api/users/${user.id}`, { data: { name: 'Alice S.' } });
// DELETE
const del = await request.delete(`/api/users/${user.id}`);
expect(del.status()).toBe(204);
// FORM body
await request.post('/login', { form: { user: 'a', pass: 'b' } });
// multipart / file
await request.post('/upload', { multipart: { file: fs.createReadStream('cv.pdf') } });
17.5 Auth patterns
// Bearer token (most common)
const res = await request.get('/api/me', {
headers: { Authorization: `Bearer ${token}` },
});
// Basic auth
const res2 = await request.get('https://httpbin.org/basic-auth/u/p', {
headers: { Authorization: 'Basic ' + Buffer.from('u:p').toString('base64') },
});
// Persistent — make a context with default headers
const api = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: { Authorization: `Bearer ${token}`, 'x-tenant': 'qa' },
});
const me = await api.get('/me');
await api.dispose();
17.6 Asserting status, body, headers, time
test('orders endpoint', async ({ request }) => {
const start = Date.now();
const res = await request.get('/api/orders/123');
const elapsed = Date.now() - start;
// Status
expect(res.status()).toBe(200);
await expect(res).toBeOK(); // shortcut for 2xx — Playwright web-first
// Headers
expect(res.headers()['content-type']).toContain('application/json');
expect(res.headers()['cache-control']).toBeDefined();
// Body — full deep equality
const body = await res.json();
expect(body).toEqual({
id: '123',
total: expect.any(Number),
items: expect.arrayContaining([expect.objectContaining({ sku: 'SKU-1' })]),
});
// Body — JSONPath-style
expect(body.items).toHaveLength(2);
expect(body.items[0].sku).toBe('SKU-1');
// Performance
expect(elapsed).toBeLessThan(500); // SLA: 500 ms
});
17.7 When to API-test vs UI-test
Use API tests when…
- Validating business logic, edge cases, error responses, validation rules
- Verifying contract: status codes, schema, headers
- Security: auth, RBAC, rate limiting
- Performance / load (with k6 / Gatling later)
- Cross-service workflows (microservice chains)
Use UI tests when…
- User flow / happy path — the "click signup → onboard → dashboard" journey
- Visual regressions and layout
- Browser-specific behaviour (file upload, drag/drop)
- Accessibility
- Cross-browser compatibility
The pyramidLots of API tests (fast, cheap), some UI E2E (slower, expensive). Don't UI-test a backend bug — write the API test first; it's 100× faster.
17.8 Hybrid tests — API setup, UI verification
This is the SDET sweet spot. Use the API to create the exact pre-conditions (a user, an order, a coupon), then verify the UI renders them correctly. No fragile UI-based setup.
import { test, expect } from '@playwright/test';
test('user sees their 3 recent orders', async ({ request, page }) => {
// 1. SETUP via API — fast, deterministic
const user = await (await request.post('/api/test/users', {
data: { email: 'qa+orders@test.com' },
})).json();
for (const total of [99, 49, 199]) {
await request.post('/api/test/orders', {
data: { userId: user.id, total },
});
}
// 2. Log in via UI (or via storageState — even faster)
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill('test-pass');
await page.getByRole('button', { name: 'Sign in' }).click();
// 3. VERIFY UI rendering
await page.goto('/orders');
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByText('$199')).toBeVisible();
// 4. CLEANUP
await request.delete(`/api/test/users/${user.id}`);
});
17.9 Schema validation
Manually equalling objects is brittle. Use a schema (Zod / Ajv) to validate shape independent of values.
import { z } from 'zod';
const Order = z.object({
id: z.string(),
total: z.number().positive(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int().min(1) })),
createdAt: z.string().datetime(),
});
test('order schema', async ({ request }) => {
const res = await request.get('/api/orders/123');
expect(res.ok()).toBeTruthy();
Order.parse(await res.json()); // throws if shape is wrong
});
17.10 Mixing UI + intercepted backend
You can also drive the UI but inject API responses via page.route to isolate frontend bugs from backend variability.
test('UI handles empty orders state', async ({ page }) => {
await page.route('**/api/orders', r => r.fulfill({ status: 200, body: '[]' }));
await page.goto('/orders');
await expect(page.getByText('No orders yet')).toBeVisible();
});
Module 5 — Interview Q&A bank
What is REST in one sentence?
A style of HTTP API design where resources are addressed by URL, the verb expresses the action (GET, POST, PUT, PATCH, DELETE), and each request is stateless — no server-side session memory between calls.
Difference between PUT and PATCH?
PUT replaces the resource entirely with the request body (missing fields become null/default). PATCH applies a partial update — only the fields you send change.
Difference between 401 and 403?
401 Unauthorized: the server doesn't know who you are (no/invalid credentials, expired token). 403 Forbidden: it knows who you are, but you're not allowed to do this (wrong role/permission). Expired session → 401, user hitting /admin → 403.
What does 201 mean and when is it returned?
201 Created — a new resource was created and is now retrievable. Typically returned by a successful POST, often with a
Location header pointing to the new URL.What does 204 mean and when is it returned?
204 No Content — the request succeeded but there is no body to return. Common for DELETE and PUT-without-body responses.
What is Playwright's APIRequestContext?
An HTTP client bundled with Playwright. Available as the
request fixture in tests; can also be created standalone via playwright.request.newContext({ baseURL, extraHTTPHeaders }). Sends real HTTP, returns Playwright Response objects that expose status, headers, json/text/buffer body, and integrate with expect(res).toBeOK().How would you authenticate API requests in tests?
Either pass
headers: { Authorization: 'Bearer xyz' } per call, or create a request context with extraHTTPHeaders set globally. For bearer flows, hit the login endpoint once, capture the token, then use it.How do you assert that a response is "OK"?
Either
expect(res.status()).toBe(200) for a specific code, or await expect(res).toBeOK() for "any 2xx". The latter is a web-first assertion and gives a richer error.When do you write an API test vs a UI test?
API for business logic, validations, error paths, security and contract — fast and cheap. UI for actual user journeys, layouts, browser-specific behaviour. Default to API-first: only escalate to UI when behaviour depends on the browser/rendering layer.
What is a hybrid test?
Set up state via the API (create user/order), then verify behaviour via the UI. You skip slow, brittle UI-based setup and still validate the rendering layer.
How do you validate the shape of a JSON response?
Use a schema library (Zod, Ajv, Joi). Define the expected schema, call
.parse(body) — throws if shape is wrong, returns a typed object on success. More robust than ad-hoc expect calls.How do you verify a response time SLA?
Capture
Date.now() before and after the request and assert the delta is under the threshold. For real load testing, use a tool like k6 or Gatling.What is the difference between request.post({ data }) and { form } and { multipart }?
data sends JSON with Content-Type: application/json. form sends URL-encoded form data (application/x-www-form-urlencoded). multipart sends multipart/form-data — needed for file uploads.How do you handle pagination in tests?
Loop: call the endpoint with the current page, assert what you need, follow the
next link/cursor from the body until exhausted. Test both the first page (typical case) and the last (boundary).What is idempotency in HTTP?
Calling the same operation twice has the same effect as calling it once. GET, PUT, DELETE are idempotent; POST is not. Important for retry strategies — you can safely retry idempotent operations on transient failures.
How do you test rate limiting?
Fire requests in a tight loop, assert that after N requests within a window you get 429 with the documented headers (
Retry-After, X-RateLimit-Remaining). Reset between tests so other tests don't get rate-limited.How do you test a POST that creates a record by side effect (DB)?
Either follow up with a GET to verify the resource exists, or assert via a direct DB query (Module 7). DB query gives stronger guarantees but couples the test to schema.
What's wrong with re-using one user across many tests?
Tests stop being isolated — one test mutating the user's state breaks the next. Either create a fresh user per test via API setup, or use a worker-scoped pool of users (one per parallelIndex slot).
What's the most common reason an API test passes locally but fails on CI?
Environment-specific data: a record that exists locally doesn't on CI, or auth tokens are issued by a different IDP. Fix by self-seeding test data via setup endpoints and using CI-secret-managed credentials.