The status code is the first thing your test should look at. It's a three-digit summary of "what happened" — assert on it before you do anything else, and a flaky/broken response will fail loudly with a useful error message instead of crashing later when you try to parse a body that isn't there. This lesson goes beyond the obvious "assert 200" and covers the more nuanced choice: which status code is correct for which operation, and how to test for the wrong-but-common shortcuts that creep into real APIs.
Why status codes come first
Three reasons to assert on status before anything else in a test:
- Fast feedback when things go wrong. A 500 error usually returns an HTML page or a stack trace, not JSON. Calling
response.json()on that body raises a parse error far less informative thanexpected 201, got 500. - Self-documenting failures. "Status 401" tells the next person reading the CI output exactly what failed. "JSONDecodeError: Expecting value" tells them nothing.
- Single source of truth. A 200 with the wrong body is still a bug, but a 500 is unambiguously a bug. Status checks catch the unambiguous cases first.
A typical assert-status-first pattern in test code:
response = requests.post(url, json=payload)
assert response.status_code == 201, f"expected 201, got {response.status_code}: {response.text}"
data = response.json()
assert data["email"] == payload["email"]The status check carries the failing body into the error message. If 500 happens, you see the body that explains it.
Picking the right code per operation
"Did it work?" isn't enough. Different operations have different correct codes:
Status codes by operation
| Success | Common alt | Wrong | |
|---|---|---|---|
| GET | 200 OK (with body) | 304 Not Modified (cache) | 201 — GET creates nothing |
| POST | 201 Created (with body, Location header) | 200 if action, not creation | 204 — POST should return the created resource |
| PUT/PATCH | 200 OK (with updated resource) | 204 No Content (when no body) | 201 — already existed, didn't create |
| DELETE | 204 No Content (deleted, nothing to return) | 200 OK (with confirmation body) | 404 only if you re-DELETE |
| Action endpoint | 200 OK or 202 Accepted (async) | 204 if action returns nothing | 201 — only for resource creation |
A few rules of thumb that flow from the table:
- POST that creates → 201 with the new resource and a
Locationheader pointing to it. - POST that performs an action (e.g.
POST /users/123/send-welcome-email) → 200, 202 (if async), or 204. - PUT/PATCH → 200 with the updated resource, or 204 if you deliberately omit a body.
- DELETE → 204 most often; 200 if you want to return a confirmation body.
If your tests assert only "is the code 2xx?" you'll miss every wrong-but-successful code. Assert the specific number.
Picking the right error code
Errors are where status code precision really pays off. The codes you should know cold and the bugs they catch:
| Wrong scenario | Right code | Common wrong code |
|---|---|---|
| Missing required field | 400 | 500 (server crash) |
| Malformed JSON in body | 400 | 500 |
| Unknown field name | 400 or silently accepted | 500 |
| No auth header | 401 | 403 |
| Bad token | 401 | 400 |
| Authenticated but no permission | 403 | 401 |
| Resource doesn't exist | 404 | 500, 200-with-empty-body |
| Method not supported on this URL | 405 | 404 |
| Duplicate (conflict with current state) | 409 | 400 |
Wrong Content-Type (e.g. XML when JSON expected) | 415 | 400 |
| Validation passes syntax but fails semantics | 422 | 400 |
| Rate limit hit | 429 | 503 |
| Server bug | 500 | 200 |
The two rows worth committing to memory:
- 401 vs 403 — auth failed vs authenticated-but-forbidden. They tell the client different things and your tests should distinguish them.
- 400 vs 422 — 400 is "I can't parse this"; 422 is "I parsed this, but it's invalid." A missing closing brace is 400; a valid JSON with
age: -10is 422.
The "200 + error" anti-pattern
Some APIs return 200 OK with an error in the body:
HTTP/1.1 200 OK
Content-Type: application/json
{"success": false, "error": "User not found"}This is bad design. It makes monitoring harder (every 200 looks healthy), breaks retry logic (clients only retry 5xx and 429), and forces every consumer to parse the body to know if their call worked. When you spot it:
- Test for it explicitly. Write a test that asserts the correct status code (404 in this case) and watch it fail. The failure documents the bug.
- Raise it. It's worth the conversation. The fix is usually a tiny code change that pays back across every consumer.
- In the meantime, assert on
body.success === trueand the status code in your tests. Don't pretend the design is fine.
Asserting status codes well
Three patterns worth memorising:
# Single expected code — most tests
assert response.status_code == 201
# Range of acceptable codes — when the API legitimately may use either
assert response.status_code in (200, 204)
# Negative assertion — "anything but a server crash"
assert response.status_code < 500The third is useful for stress/load tests where the exact code may vary but a 5xx is always a bug.
For broader assertions, group by family:
def is_success(code): return 200 <= code < 300
def is_client_error(code): return 400 <= code < 500
def is_server_error(code): return 500 <= code < 600Server errors are the unambiguous bug class — every 5xx in your test suite warrants investigation, even if the test passes overall.
⚠️ Common mistakes
- Asserting only "code < 500." A 200 with the wrong body, a 401 instead of 403, a 404 when you expected 200 — all pass this loose check. Use the specific code your API contract documents.
- Treating every error as 500. "Something went wrong → 500" hides every input bug behind a generic server error. 4xx codes communicate what the client did wrong and let consumers react.
- Not asserting the status code first. Letting a body parse fail before the status check produces uselessly cryptic failures. Status assertion should be line one of every test.
🎯 Practice task
Build a status-code matrix for one endpoint. 20-25 minutes.
- Pick an endpoint with at least three documented behaviours — e.g.
POST /api/users(create, validation error, conflict). - List every documented status code the endpoint can return. Cross-check against the HTTP Status Codes cheat sheet — do you spot any codes the docs missed (e.g. 415 for wrong content type)?
- For each documented code, write the curl call that triggers it. Run them. Note the actual code.
- Try one undocumented failure: send malformed JSON. What status came back? Does it match the spec or hide behind a generic 500?
- Stretch: find the worst offender on your team's API for "200 with error in body." Write a test that asserts the correct status code. Watch it fail. Take it to standup.
You can now write tests that fail loudly and accurately on status. The next lesson goes one layer deeper — validating the body against a schema.