API TESTING

REST API Status Codes: What They Actually Mean

Status codes are the first thing your test should check — before you parse a single field in the response body. In practice, I see a lot of test suites that jump straight to asserting a JSON value and never verify the HTTP status at all. That's a problem, because a server can return a 200 with an error message in the body just as easily as it can return a 404 with a partial payload. The status code is the contract the API is making with your client, and your tests need to hold it to that contract.

The good news is that the status code system is well-structured once you understand the five classes and the intent behind the most common codes in each one. The bad news is that "well-structured" doesn't mean "consistently implemented." Real APIs break the rules in predictable ways, and knowing the spec makes it much easier to spot when a backend is misbehaving — which is exactly when your tests earn their keep. If you're still getting comfortable with how HTTP requests and responses fit together, the fundamentals of REST and HTTP testing are worth a read before diving in here.

In this article I'll walk through what each status code class actually signals, call out the codes that trip teams up most often, and show you the assertion patterns I reach for in Python and pytest. By the end you'll have a mental model you can apply immediately — whether you're writing a new test from scratch or reviewing someone else's suite.

API Testing using Python, Behave, VS Code & GitHub Copilot

Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!

Learn more

The Five Status Code Classes and What Each One Is Actually Telling You

HTTP status codes are grouped into five classes by their leading digit. That leading digit is doing real work — it tells you the category of the outcome before you read anything else.

  • 1xx — Informational: The server received the request and is continuing to process it. You almost never assert these in REST API tests; they're mostly relevant in streaming or WebSocket upgrade scenarios.
  • 2xx — Success: The request was received, understood, and accepted. This is the class your happy-path tests live in.
  • 3xx — Redirection: The client needs to take an additional action, usually following a new URL. REST clients often follow redirects automatically, which can silently swallow bugs.
  • 4xx — Client Error: The request was bad. The problem is on the caller's side — wrong credentials, malformed input, missing resource.
  • 5xx — Server Error: The request was valid but the server failed to fulfill it. The problem is on the server's side.

The 4xx vs. 5xx distinction matters enormously in testing. A 4xx means your test input or your request construction is wrong; a 5xx means the API itself is broken. Confusing the two leads to wasted debugging time. I've seen teams spend hours "fixing" a test that was actually catching a real server bug — they assumed the 500 was their fault because they hadn't internalized this boundary.

One practical tip: never assert only status_code != 200 as your failure condition. Assert the specific code you expect. A 201 is not the same as a 200, and a 204 has no body — asserting a 200 when the API correctly returns 201 on a POST is a false failure waiting to happen.

# pytest — assert the exact code, not just "success"
response = requests.post(BASE_URL + "/users", json=payload, headers=headers)
assert response.status_code == 201, (
    f"Expected 201 Created, got {response.status_code}: {response.text}"
)

Including response.text in the assertion message is a small habit that saves a lot of time when tests fail in CI — you see the error body without having to re-run the request manually.

The 2xx, 4xx, and 5xx Codes That Trip Up API Test Suites Most Often

Let's get specific. Here are the codes I see misunderstood or mishandled most often in real test suites, grouped by class.

2xx — Success Codes

200 OK — The all-purpose success code. Fine for GET and PUT responses, but if your POST returns 200 instead of 201, that's a spec violation worth flagging.

201 Created — The correct response to a successful POST that creates a resource. The response should include a Location header pointing to the new resource. Test for that header — it's part of the contract.

assert response.status_code == 201
assert "Location" in response.headers, "201 Created should include a Location header"

204 No Content — Returned by DELETE and some PUT/PATCH operations when there's nothing meaningful to return. The key point: there is no body. If your test tries to call response.json() on a 204, you'll get a decode error. Always gate your body assertions on the status code.

response = requests.delete(BASE_URL + f"/users/{user_id}", headers=headers)
assert response.status_code == 204
# Do NOT call response.json() here — there is no body

4xx — Client Error Codes

400 Bad Request — The server couldn't parse or validate the request. Your negative-path tests should assert 400 when you send malformed JSON, missing required fields, or invalid field types. Also assert that the response body contains a useful error message — a bare 400 with no explanation is a usability bug.

401 Unauthorized vs. 403 Forbidden — These two are confused constantly. 401 means the client is not authenticated — no valid credentials were provided. 403 means the client is authenticated but doesn't have permission. If you send a request with no token and get a 403, that's a backend bug. Your auth-failure tests should assert 401; your authorization-boundary tests should assert 403.

404 Not Found — The resource doesn't exist. Straightforward, but watch out: some APIs return 404 when they should return 400 (e.g., a malformed ID in the path). Your tests should distinguish between "resource doesn't exist" and "request was invalid."

422 Unprocessable Entity — Increasingly common in modern REST APIs (especially those built with FastAPI or similar frameworks). The request was well-formed syntactically but failed semantic validation — for example, a date range where the end date precedes the start date. If your API uses 422, your validation-failure tests should assert 422, not 400.

429 Too Many Requests — Rate limiting. Your test suite should include at least one test that verifies the API returns 429 when the limit is exceeded, and that the response includes a Retry-After header. Rate-limit behavior is often untested and frequently broken in production.

5xx — Server Error Codes

500 Internal Server Error — The server crashed or hit an unhandled exception. This should never be an expected response in your happy-path tests. If a 500 shows up, it's a bug — log it, fail the test loudly, and don't swallow it with a broad try/except.

503 Service Unavailable — The server is temporarily unable to handle the request (overloaded or down for maintenance). Relevant for resilience testing and retry-logic tests, but not something you should see in a normal functional test run.

A pattern that shows up often in mature test suites is a shared assertion helper that fails with a clear message for any 5xx, regardless of which specific code appears:

def assert_no_server_error(response):
    assert response.status_code < 500, (
        f"Server error {response.status_code} on {response.request.method} "
        f"{response.request.url}: {response.text}"
    )

Call this at the top of every test before your specific assertions. It acts as a safety net that catches unexpected server failures without requiring you to predict every possible 5xx code.

Asserting Status Codes Correctly in pytest and Behave — Patterns That Hold Up in CI

Knowing what the codes mean is half the job. The other half is asserting them in a way that makes failures readable, maintainable, and trustworthy in a CI pipeline. Here are the patterns I keep coming back to.

Be specific, and put the status code assertion first

Always assert the status code before you assert anything about the response body. If the status is wrong, the body assertions are meaningless — and they'll produce confusing errors if you try to parse a body that doesn't exist or has an unexpected shape.

def test_get_user_returns_200(api_client, created_user_id):
    response = api_client.get(f"/users/{created_user_id}")
    # Status first
    assert response.status_code == 200, f"Unexpected status: {response.status_code} — {response.text}"
    # Body assertions only after status is confirmed
    data = response.json()
    assert data["id"] == created_user_id
    assert "email" in data

Behave step definitions — status code steps belong in a shared steps file

In a Behave suite, I keep HTTP status code steps in a dedicated http_steps.py file so they're reusable across every feature file. The step text reads naturally in Gherkin and the implementation stays simple:

# features/steps/http_steps.py
from behave import then

@then("the response status code is {expected_code:d}")
def step_check_status_code(context, expected_code):
    actual = context.response.status_code
    assert actual == expected_code, (
        f"Expected HTTP {expected_code}, got {actual}. "
        f"Body: {context.response.text}"
    )
Scenario: Delete a user removes the resource
  Given a user exists with id "abc123"
  When I send a DELETE request to "/users/abc123"
  Then the response status code is 204
  And a GET request to "/users/abc123" returns status code 404

That second step — verifying that a 404 follows a successful delete — is a pattern worth building into every DELETE test. It confirms the resource is actually gone, not just that the delete endpoint returned a success code.

Parametrize negative-path tests across multiple invalid inputs

For 400/422 validation testing, pytest's parametrize decorator lets you cover a range of invalid inputs without duplicating test logic:

import pytest
import requests

INVALID_PAYLOADS = [
    ({}, "empty body"),
    ({"email": "not-an-email"}, "malformed email"),
    ({"email": "user@example.com"}, "missing required name field"),
    ({"name": "x" * 300, "email": "user@example.com"}, "name exceeds max length"),
]

@pytest.mark.parametrize("payload,description", INVALID_PAYLOADS)
def test_create_user_returns_422_for_invalid_input(payload, description):
    response = requests.post(BASE_URL + "/users", json=payload)
    assert response.status_code == 422, (
        f"Case '{description}': expected 422, got {response.status_code} — {response.text}"
    )

This pattern scales well and makes it easy to add new edge cases as you discover them. It's also a natural fit for the kind of real-world validation scenarios that surface during exploratory testing or bug triage.

Don't trust redirects silently

By default, the Python requests library follows 3xx redirects automatically. That means a 301 or 302 can silently become a 200 in your test, hiding the fact that the API is redirecting instead of serving the resource directly. If redirect behavior matters for your API, disable auto-follow and assert explicitly:

response = requests.get(BASE_URL + "/old-endpoint", allow_redirects=False)
assert response.status_code == 301
assert response.headers["Location"] == BASE_URL + "/new-endpoint"

Status codes are one of those fundamentals that reward careful attention. The more precisely your tests assert them, the more signal you get when something breaks — and the less time you spend chasing down whether a failure is your test's fault or the API's. If you're building out a broader test strategy and want to see how status code assertions fit into a full testing approach, the comprehensive guide for aspiring testers covers the bigger picture well.

Ready to level up your testing skills?

Python Course Node Course