How it works
The explanation, with examples you can run.
What to check on every endpoint
- Status codes that mean something:
201with aLocationheader for a create,400for malformed input,401when there are no valid credentials,403when the caller is known but not allowed,404for a missing resource,409for a conflict such as a duplicate. - The body's shape: required fields are present and have the right types. Use a JSON Schema or the OpenAPI spec as the single source, so tests and documentation can't drift apart.
- The effect: after a
POST, the record exists. After a failed request, nothing changed. - Authorisation per object: try each endpoint as the owner, as another user, and with no token at all.
Idempotency and retries
Networks drop responses, and clients retry. GET, PUT and DELETE are idempotent by definition: repeating them leaves the server in the same state, even if the response changes (a second DELETE may return 404). POST is not, so APIs that take payments or create orders accept an idempotency key: a client-chosen id sent in a header, so the server can recognise a retry and replay the first result instead of acting again.
A tiny in-memory handler shows what a test should prove:
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
A real implementation also stores keys durably, expires them, and rejects a reused key that arrives with a different body.
The same checks with Playwright
Playwright's request fixture sends HTTP calls without opening a browser, using baseURL from the config:
import { expect, test } from "@playwright/test" test("a retried create with the same key makes one order", async ({ request }) => { const headers = { Authorization: `Bearer ${process.env.ALICE_TOKEN}`, "Idempotency-Key": crypto.randomUUID() } const data = { sku: "MUG", qty: 2 } const first = await request.post("/api/orders", { headers, data }) const retry = await request.post("/api/orders", { headers, data }) expect(first.status()).toBe(201) expect((await retry.json()).id).toBe((await first.json()).id)}) test("Alice cannot read Bob's order", async ({ request }) => { const res = await request.get(`/api/orders/${process.env.BOB_ORDER_ID}`, { headers: { Authorization: `Bearer ${process.env.ALICE_TOKEN}` }, }) expect([403, 404]).toContain(res.status())})Create the users and orders these tests need through the API or a factory at the start of the run, not by hand in a shared environment.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.