Everything needed is already covered: dicts, loops, functions, assert and the
pytest shape. What is left is a project that sends the requests itself and
checks what came back.
How a test project is laid out
tests/
conftest.py shared setup: client, login, data
test_products.py catalogue tests
test_orders.py order tests
requirements.txt libraries
Exactly two libraries are needed: pytest runs the tests, requests sends the
requests.
pip install pytest requests
conftest.py holds the setup every test needs: a client that knows the address
and a login. These are fixtures — how they work is covered in
the pytest article.
import pytest
import requests
BASE = "https://shop.example.com/api"
@pytest.fixture
def client():
return requests.Session()
@pytest.fixture
def headers(client):
response = client.post(f"{BASE}/auth/login",
json={"email": "anna@example.com", "password": "secret123"})
assert response.status_code == 200, "login failed — everything after this is meaningless"
return {"Authorization": "Bearer " + response.json()["token"]}
What to check in a response
Four layers, from cheap to valuable.
The status code — 200, 201, 404, 422. One line,
assert response.status_code == 201, and it is in every test.
The fields that matter. This is where people slip most often: they compare the whole response body and tie the test to every field the service returns.
live example
# error: don't do this — compared the whole body and failed on a new field
body = {"id": "ord-042", "status": "NEW", "total": 4990, "createdAt": "2026-03-01"}
assert body == {"id": "ord-042", "status": "NEW", "total": 4990}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
# do this instead — check the fields the test was written for
body = {"id": "ord-042", "status": "NEW", "total": 4990, "createdAt": "2026-03-01"}
assert body["status"] == "NEW"
assert body["total"] == 4990
print("checked the fields that matter")
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The service added createdAt and broke nothing, yet the first test went red out
of nowhere.
Headers the client relies on — for example Location on a created object:
the client follows it to the result.
Consequences in the system. The most valuable and most often forgotten: the order was created — did the stock go down? A 201 does not say that.
def test_stock_goes_down(client, headers):
before = client.get(f"{BASE}/products/p-01").json()["inStock"]
client.post(f"{BASE}/orders",
json={"productId": "p-01", "quantity": 2}, headers=headers)
after = client.get(f"{BASE}/products/p-01").json()["inStock"]
assert after == before - 2
Rejections matter as much as happy paths
Tests that only walk the happy path miss half the defects. For every scenario ask three questions: what happens with invalid data, without permissions, on a conflict.
def test_order_without_token(client):
response = client.post(f"{BASE}/orders", json={"productId": "p-01", "quantity": 1})
assert response.status_code == 401
def test_empty_body(client, headers):
response = client.post(f"{BASE}/orders", json={}, headers=headers)
assert response.status_code == 422
fields = [d["field"] for d in response.json()["error"]["details"]]
assert "productId" in fields
And separately — that the rejection changed nothing: the stock is intact, no order was created. A rejection that managed to change data is a common finding in permission testing.
Data is prepared by request, not by clicking
A test that needs a paid order does not place it through the interface — it creates the state with a request: faster and repeatable.
@pytest.fixture
def paid_order(client, headers):
return client.post(f"{BASE}/orders",
json={"productId": "p-01", "quantity": 1}, headers=headers).json()
And every test prepares its own data. Tests that hand state to each other break when the run order changes and fail "in turns" for no reason.
A green test proves nothing
A test is code too, and code has bugs. A test that checks nothing is always green.
# don't do this — the request goes out, but nothing is checked
def test_order(client, headers):
client.post(f"{BASE}/orders", json={"productId": "p-01", "quantity": 1}, headers=headers)
assert True
# do this instead — the test ends with a check of the response
def test_order(client, headers):
response = client.post(f"{BASE}/orders", json={"productId": "p-01", "quantity": 1}, headers=headers)
assert response.status_code == 201
assert response.json()["status"] == "NEW"
There is one honest way to check your tests: break the service and see whether they notice. Turn off validation, drop the token check, stop decreasing the stock — how many tests go red? The ones still green check nothing.
In the API autotest trainer this happens by itself: your tests are run against nine broken versions of the service, and the report shows which breakages they caught. A task counts only if the test catches something.
In short
- A project needs two libraries:
pytestandrequests; shared setup lives inconftest.py. - Four layers get checked: status code, meaningful fields, headers, and consequences in the system.
- The body is never compared as a whole: a new field turns such a test red for no reason.
- Rejections are checked alongside happy paths — and separately, that the rejection changed nothing.
- A green test proves nothing until it has shown that it goes red on a broken service.
What to read next
- pytest: your first real test — if you need the shape again.
- Working with data — how to pull fields out of a response.
- assert and errors — how to make a failing test readable.
- API testing in Postman — the same requests by hand, for a quick look.