Testing an HTTP API
Status codes, headers, schemas and error bodies — testing the contract rather than the handler, including the authorisation cases everyone forgets.
Status codes, headers, schemas and error bodies — testing the contract rather than the handler, including the authorisation cases everyone forgets.
For most backend services this is the highest-value automated testing available. An API test exercises routing, deserialisation, validation, authorisation, business logic, the database and serialisation in one round trip — nearly everything a browser test covers, in a fraction of the time and without a browser's fragility.
By the end of this lesson you will know what to assert on beyond the status code, how to test a sequence of requests, and the authorisation cases that are missing from most suites.
Build a request, send it through the real application, assert on the response.
def test_creating_a_contact_returns_it_with_an_id(client, admin_token):
response = client.post(
"/contacts",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "Ada", "email": "ada@example.com"},
)
assert response.status_code == 201
body = response.json()
assert body["name"] == "Ada"
assert body["email"] == "ada@example.com"
assert isinstance(body["id"], int)The client is a test client that runs your real application in-process —
TestClient in FastAPI, test_client in Flask, supertest for Express.
No network, no server to start, milliseconds per request, and every layer
of your own code executes.
That last point is the one that matters. Calling the handler function directly would skip routing, body parsing, the authentication middleware and the error handlers — all real code with real defects, and much of the reason to write the test.
A status-code-only test is a weak test. Five things are worth checking, and each catches a different class of defect.
The status code, precisely
201 for a created resource, not response.ok. The
difference between 200 and 201, or 401 and 403, is a
contract clients rely on.
The response body's shape
Every field a client depends on, present and correctly typed. This is where a dropped or renamed field is caught.
Headers that matter
Location on a created resource, Content-Type, cache
directives, CORS headers if you have them.
The persisted state
Read it back, with a second request or a fresh database
session — because a 201 that saved nothing is exactly the
seam bug from the isolation lesson.
The error body
When a request is rejected, clients need to know why in a stable, machine-readable form.
Asserting on a code rather than on a human-readable message is
deliberate: the message is expected to change, and a test that breaks when
somebody improves the wording is a test that punishes improvement.
This is the section worth reading twice, because these cases are missing from most suites and they are the ones that produce serious incidents.
For every endpoint that touches data belonging to someone, there are five tests, not one:
The fourth and fifth are the important ones, and they are the automated form of the "change an id in the URL" check from the foundations course. Authorisation is routinely enforced in the listing — which filters by account — and forgotten on the endpoints that take an id, because the id came from a listing the user was allowed to see.
Note the 404 rather than 403 in that fourth test. Returning "forbidden"
tells the caller the record exists, which is itself a small disclosure;
"not found" reveals nothing. Whichever your API chooses, the test should
pin it, because it is a decision and not an accident.
The fifth test asserts on the data, not the status code, which is
deliberate: an endpoint can return 403 and still have performed the
update if the check happens after the write.
Bad — the happy path only, on a permission-sensitive endpoint:
Good — the same endpoint, with the four cases that matter:
The bad test is not wrong — it belongs in the suite. On its own it means an endpoint that lets any authenticated user edit any record in the system passes every test you have. That is not a hypothetical: it is the most common serious web vulnerability there is, and it is invisible to happy-path testing by construction.
Some behaviour only exists across requests, and the API level is the cheapest place to test it.
The second test is the "already done" shape from the foundations course. Whichever answer your API gives, it should be a decision — and a test is what turns it from an accident into one.
Two more sequence cases worth having:
Pagination boundaries. Create 21 records with a page size of 20, fetch both pages, and assert that the two pages together contain each record exactly once. Off-by-one and unstable-sort bugs hide a record from a user without producing an error anywhere.
Idempotency. Send the same creating request twice — with the same idempotency key if your API supports one — and assert one record exists. This is the double-click test, automated.
Four habits.
Do not test logic here. Twenty invalid email formats belong in unit tests. One case at the API level confirms the validation is wired up and the error shape is right.
Assert on codes, not prose. Field names and error codes are contract; messages are copy.
Give each test its own data. Factories, unique values, no shared records — the independence rules from earlier apply exactly.
Keep the whole API suite in the low minutes. These tests are fast individually; the cost creeps in through per-test setup. Share the container and the migrations at session scope.
A reasonable target for a service with fifty endpoints: two to four tests per endpoint on average — the happy path, the validation shape, and the authorisation cases — running in under two minutes.
API tests cover the service. They cannot tell you whether the interface a user actually touches works — the front end could be entirely broken and every one of these tests would pass. That is what browser tests are for, and Playwright is next.
Before that, pick one endpoint in your own API that takes an id and write the fourth and fifth tests from this lesson against it. If either passes unexpectedly, you have found something worth reporting today.
# Assert on five things, not one
status code exactly 201, not response.ok
body shape every field a client depends on, correctly typed
headers Location, Content-Type, cache, CORS
persisted state read it back — a 201 that saved nothing is a bug
error body field and CODE, never the human-readable message
# The authorisation cases most suites are missing
no token -> 401
expired / invalid token -> 401
wrong role -> 403
another account's id -> 404 (or 403 — pin whichever you chose)
another account's id on -> assert the RECORD IS UNCHANGED, because
a write a 403 can still have written first
# enforcement is usually right in the LISTING and forgotten on
# the endpoints that take an id. That is the most common serious
# web vulnerability there is, and happy-path tests cannot see it.
# Sequences — only testable across requests
create -> get 200 -> delete 204 -> get 404
delete twice: whatever it returns, make it a DECISION
pagination: 21 records, page size 20, each record exactly once
idempotency: the same create twice -> one record
# Test through the real interface
# use the framework's test client: routing, body parsing, auth
# middleware and error handlers all run
# calling the handler function directly skips all of it
# Keep it honest and fast
no logic testing here — 20 invalid emails are unit tests
assert on codes, not prose — messages are meant to change
own data per test: factories, unique values, nothing shared
share container and migrations at session scope
target: 2-4 tests per endpoint, whole suite in the low minutes
# If you publish OpenAPI, validate responses against it
# one helper catches every accidental contract changedef test_a_missing_email_is_rejected_with_a_useful_error(client, token):
response = client.post(
"/contacts",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Ada"},
)
assert response.status_code == 422
body = response.json()
assert body["errors"][0]["field"] == "email"
assert body["errors"][0]["code"] == "required"def test_listing_requires_authentication(client):
assert client.get("/contacts").status_code == 401
def test_an_expired_token_is_rejected(client, expired_token):
response = client.get(
"/contacts", headers={"Authorization": f"Bearer {expired_token}"}
)
assert response.status_code == 401
def test_a_standard_user_cannot_import(client, standard_token):
response = client.post(
"/contacts/import",
headers={"Authorization": f"Bearer {standard_token}"},
)
assert response.status_code == 403
def test_a_user_cannot_read_another_accounts_contact(
client, user_factory, contact_factory
):
other = user_factory()
theirs = contact_factory(account=other.account)
mine = user_factory()
response = client.get(
f"/contacts/{theirs.id}",
headers={"Authorization": f"Bearer {token_for(mine)}"},
)
assert response.status_code == 404
def test_a_user_cannot_update_another_accounts_contact(
client, user_factory, contact_factory
):
other = user_factory()
theirs = contact_factory(account=other.account, name="Original")
mine = user_factory()
client.patch(
f"/contacts/{theirs.id}",
headers={"Authorization": f"Bearer {token_for(mine)}"},
json={"name": "Changed"},
)
assert reload(theirs).name == "Original"def test_a_contact_can_be_updated(client, admin_token, contact):
response = client.patch(
f"/contacts/{contact.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "Changed"},
)
assert response.status_code == 200# ...as above, plus:
# no token -> 401
# expired token -> 401
# wrong role -> 403
# another account's id -> 404, and the record is UNCHANGEDdef test_a_contact_disappears_after_deletion(client, token):
headers = {"Authorization": f"Bearer {token}"}
created = client.post(
"/contacts", headers=headers,
json={"name": "Ada", "email": "ada@example.com"},
).json()
assert client.get(f"/contacts/{created['id']}",
headers=headers).status_code == 200
assert client.delete(f"/contacts/{created['id']}",
headers=headers).status_code == 204
assert client.get(f"/contacts/{created['id']}",
headers=headers).status_code == 404
def test_deleting_twice_is_not_an_error(client, token, contact):
headers = {"Authorization": f"Bearer {token}"}
first = client.delete(f"/contacts/{contact.id}", headers=headers)
second = client.delete(f"/contacts/{contact.id}", headers=headers)
assert first.status_code == 204
assert second.status_code in {204, 404}# The shape
response = client.post(
"/contacts",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Ada", "email": "ada@example.com"},
)
assert response.status_code == 201