← back to section

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: pytest and requests; shared setup lives in conftest.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.