03 · API Testing with requests + pytest¶
A browser test that checks "the order total shows ₹1,499" spends thirty seconds launching Chrome, logging in, and clicking through a cart — to verify one number the server computed. The API test that checks the same number takes 200 milliseconds and fails with a message that points at the actual service. UI tests prove the screen works; API tests prove the system works, and they're the layer where most of your automated coverage should live.
This module uses requests plus plain pytest. No special framework is needed —
an HTTP response is just an object with a status code, headers, and a body.
1. Your first API test¶
Every example below runs against the free JSONPlaceholder service, so you can copy the file and run it right now.
# test_api.py
import requests
BASE_URL = "https://jsonplaceholder.typicode.com"
def test_get_post_returns_200():
response = requests.get(f"{BASE_URL}/posts/1", timeout=10)
assert response.status_code == 200
assert response.headers["Content-Type"].startswith("application/json")
Three things are already worth noticing:
timeout=10is not optional.requestshas no default timeout. Omit it and a hung service hangs your CI job forever, not for ten seconds.- Assert on
status_code, not onresponse.ok—okis true for anything under 400, so a 302 redirect passes a check you meant as "the resource exists". - Headers are part of the contract. A service that starts returning
text/html(a login page, a proxy error) will still hand you a 200.
2. A session fixture¶
Creating a fresh TCP connection per request is wasteful, and every test repeating
headers={"Accept": "application/json"} is the same duplication POM fixed for UI
tests. A requests.Session fixture solves both.
import pytest
import requests
BASE_URL = "https://jsonplaceholder.typicode.com"
@pytest.fixture(scope="session")
def api():
with requests.Session() as session:
session.headers.update({"Accept": "application/json"})
yield session
Session scope means one connection pool for the whole run. Auth headers, a base retry policy, and TLS settings all belong here too:
@pytest.fixture(scope="session")
def api(auth_token):
with requests.Session() as session:
session.headers.update({
"Accept": "application/json",
"Authorization": f"Bearer {auth_token}",
})
yield session
3. Asserting on the body¶
Status codes tell you the request was accepted. The body tells you the answer was right. Check both — and check the shape, not just one field, so a service that silently drops a key fails loudly.
def test_post_payload_shape(api):
body = api.get(f"{BASE_URL}/posts/1", timeout=10).json()
assert set(body) == {"userId", "id", "title", "body"}
assert body["id"] == 1
assert isinstance(body["title"], str) and body["title"]
set(body) == {...} catches both a missing key and an unexpected new one. If you
only want "these keys must exist, extras are fine", use
assert {"id", "title"} <= set(body) instead — pick deliberately, because the
two choices catch different regressions.
4. Writes, query parameters, and errors¶
def test_missing_post_returns_404(api):
response = api.get(f"{BASE_URL}/posts/999999", timeout=10)
assert response.status_code == 404
def test_create_post(api):
payload = {"title": "regression sweep", "body": "nightly", "userId": 7}
response = api.post(f"{BASE_URL}/posts", json=payload, timeout=10)
assert response.status_code == 201
created = response.json()
assert created["title"] == payload["title"]
assert "id" in created
def test_query_filter(api):
response = api.get(f"{BASE_URL}/comments", params={"postId": 1}, timeout=10)
comments = response.json()
assert response.status_code == 200
assert len(comments) == 5
assert all(c["postId"] == 1 for c in comments)
Use json=payload, not data=payload. json= serialises the dict and sets
Content-Type: application/json; data= form-encodes it and many APIs will
reject that with a confusing 400.
Build query strings with params={...} rather than f-stringing them into the
URL — requests handles the URL-encoding, so a search term containing & or a
space doesn't quietly corrupt the request.
5. Parametrizing across resources¶
import pytest
@pytest.mark.parametrize("post_id", [1, 2, 3])
def test_each_post_belongs_to_a_user(api, post_id):
body = api.get(f"{BASE_URL}/posts/{post_id}", timeout=10).json()
assert body["userId"] >= 1
6. Real run¶
============================= test session starts ==============================
platform darwin -- Python 3.11.2, pytest-9.1.1, pluggy-1.6.0
collected 9 items
test_api.py::test_get_post_returns_200 PASSED [ 11%]
test_api.py::test_post_payload_shape PASSED [ 22%]
test_api.py::test_missing_post_returns_404 PASSED [ 33%]
test_api.py::test_create_post PASSED [ 44%]
test_api.py::test_each_post_belongs_to_a_user[1] PASSED [ 55%]
test_api.py::test_each_post_belongs_to_a_user[2] PASSED [ 66%]
test_api.py::test_each_post_belongs_to_a_user[3] PASSED [ 77%]
test_api.py::test_query_filter PASSED [ 88%]
test_api.py::test_response_time_under_budget PASSED [100%]
============================== 9 passed in 3.69s ===============================
Nine API tests in 3.7 seconds — including nine real network round trips. The equivalent coverage through the UI would run several minutes.
7. Failure messages that actually help¶
A bare assert response.status_code == 200 tells you the number was wrong but
nothing about why. When a test fails at 3 a.m. in CI, you want the body:
def test_create_order(api):
response = api.post(f"{BASE_URL}/posts", json={"title": "x"}, timeout=10)
assert response.status_code == 201, (
f"POST /posts returned {response.status_code}\n"
f"body: {response.text[:500]}"
)
E AssertionError: POST /orders returned 422
E body: {"error":"validation_failed","details":[{"field":"userId","msg":"required"}]}
E assert 422 == 201
That message diagnoses the bug without a single re-run.
8. Timing as an assertion¶
response.elapsed is a timedelta covering the time from sending the request to
finishing the headers — free performance monitoring on tests you already run.
def test_response_time_under_budget(api):
response = api.get(f"{BASE_URL}/posts", timeout=10)
assert response.elapsed.total_seconds() < 2.0
Keep the budget loose (a smoke-level "not catastrophically slow" check). A tight budget in a shared CI runner is a flaky test, not a performance test — real load testing belongs in a dedicated tool, not your functional suite.
9. Traps¶
Tests that depend on each other's writes
created_id = None # module-level state — don't
def test_create():
global created_id
created_id = api.post(...).json()["id"]
def test_delete():
api.delete(f"{BASE_URL}/posts/{created_id}")
-k test_delete, or in parallel (module 08), or in a random
order, and created_id is None. Each test must create what it needs — use
a fixture that creates and cleans up its own record.
.json() on a non-JSON response
When a gateway returns an HTML error page, response.json() raises
requests.exceptions.JSONDecodeError and the traceback says nothing about
the 502 that caused it. Assert the status code before parsing the body —
the order of your assertions is part of your error message.
Asserting on data you don't control
assert len(response.json()) == 100 against a shared staging database
passes until somebody adds a row. Assert on invariants (every returned
comment has postId == 1), or on records your own fixture created.
How It Actually Works¶
API testing with requests cuts out an entire layer that Selenium tests pay for: no
browser process, no rendering engine, no WebDriver wire protocol — your test process
opens a TCP socket directly to the server and speaks HTTP itself, which is why API
tests typically run 10-100x faster than an equivalent UI test.
Under the hood, requests.get(url) builds a PreparedRequest (method, headers,
body, encoded query string), hands it to a Session object's connection pool
(urllib3 underneath), which either reuses a keep-alive TCP connection to that host
or opens a new one, performs the TLS handshake if HTTPS, writes the raw HTTP/1.1
request line and headers to the socket, and blocks on recv() until the response
status line, headers, and body arrive. Connection pooling (reusing a Session object
across calls instead of calling requests.get fresh each time) avoids repeating the
TCP handshake and TLS negotiation per request — a meaningful speedup in a test suite
making hundreds of API calls.
Schema validation (jsonschema.validate(...)) works by recursively walking your JSON
document alongside the schema's declared structure, checking each node's type,
required keys, and constraints against the schema's rules — this is a structural
recursive-descent check, not a string comparison, which is why it can validate deeply
nested, order-independent JSON that a naive string/dict equality check would false-
negative on for trivial reasons like key ordering.
Cheat sheet¶
| Task | Code |
|---|---|
| GET with params | session.get(url, params={"q": "shoes"}, timeout=10) |
| POST JSON | session.post(url, json=payload, timeout=10) |
| Auth for all requests | session.headers.update({"Authorization": ...}) |
| Parse body | response.json() — after checking the status code |
| Raw body for error messages | response.text |
| Fail on 4xx/5xx immediately | response.raise_for_status() |
| Response duration | response.elapsed.total_seconds() |
| Redirect history | response.history |
| Never omit | timeout= |
| Status | Means | Typical test |
|---|---|---|
| 200 | OK | Read succeeded, body matches |
| 201 | Created | POST returned the new resource + id |
| 204 | No content | DELETE succeeded, body is empty |
| 400 | Bad request | Malformed payload rejected |
| 401 / 403 | Unauthenticated / forbidden | Token missing vs. token lacks the role |
| 404 | Not found | Unknown id |
| 422 | Validation failed | Well-formed but semantically invalid input |
| 429 | Rate limited | Client backs off rather than hammering |
Exercise¶
Using https://jsonplaceholder.typicode.com:
- Write the
apisession fixture from section 2 and port all your tests to it. Confirm with-vthat the fixture is created once, not once per test. - Write a test for
GET /users/1that asserts the full key set of the nestedaddressobject, then delete one key from your expected set and read the failure message pytest produces for set comparison. - Write a fixture that POSTs a new post, yields its id, and DELETEs it in teardown. Use it in two tests and confirm neither leaks data into the other.
- Deliberately drop
timeout=from one request and point it athttps://httpbin.org/delay/10. Record how long the test hangs, then add the timeout back and record the exception name you get instead. - Write a negative test for
POST /postswith an empty body, and give its assertion a message that prints the status code and the first 500 characters of the response so the failure is diagnosable without re-running.