Backend engineer path · Data and tests · Topic 13 of 18

API testing: status codes, schemas, idempotency, auth

Not started

API tests call your service over HTTP the way a client would, and check the status code, the body's shape, and what happened on the server. They are faster and more stable than browser tests and test the same rules. The bugs that hurt most here are the unhappy paths: missing or wrong credentials, other users' data, and requests that run twice.

Why it matters: An API is a promise to every client at once. Tests at this layer are fast, independent of the UI, and catch the bugs that leak data or charge twice.

  1. 1Status and shape

    Check the status code and the body's shape and values. A 200 with the wrong JSON is still a bug.

  2. 2The unhappy paths

    Invalid input, missing auth, someone else's resource, a duplicate, a conflict. Most API bugs live here.

  3. 3Idempotency

    Send the same request twice and check that it only happens once. PUT and DELETE should be idempotent by design; POST needs a key.

  4. 4Authorisation per object

    A valid token is not permission. Test that user A can't read or change user B's things.

1

How it works

The explanation, with examples you can run.

What to check on every endpoint

  • Status codes that mean something: 201 with a Location header for a create, 400 for malformed input, 401 when there are no valid credentials, 403 when the caller is known but not allowed, 404 for a missing resource, 409 for 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:

⌘/Ctrl + Enter
OutputNot run yet

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.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Alice's valid token can GET /orders/123, which belongs to Bob, and gets 200. What kind of bug is that?
  2. 2.DELETE /items/7 returns 204 the first time and 404 the second. Is DELETE still idempotent?
  3. 3.A test only asserts expect(res.status()).toBe(200). What can still be broken?

0 of 3 right so far. Get all three to finish this topic.