← back to section

By now you have everything: variables and strings, dicts and lists, loops, functions, assert and the pytest shape. What is left is assembling the thing it was all for — API autotests.

How a test project is laid out

tests/
  conftest.py          shared fixtures: client, login, test 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 creates the client — an object that remembers the address and the headers:

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"]}

In our trainer the client fixture is ready and talks to a toy service — the test code is the same, with nothing to configure.

What to check in a response

Four layers, from cheap to valuable.

The status code. First and always: 200, 201, 404, 422.

The fields that matter — not the whole body: comparing against a complete dict breaks on any new field, and adding a field does not break compatibility.

Headers that are part of the contract — for example Location on a created object.

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.

before = client.get(f"{BASE}/products/p-01").json()["inStock"]

client.post(f"{BASE}/orders", json={"productId": "p-01", "quantity": 2}, headers=headers)

assert client.get(f"{BASE}/products/p-01").json()["inStock"] == 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.

live example

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
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Free week →

And separately — that the rejection changed nothing: the stock is intact, no order was created. A rejection that managed to change data is a classic finding in permission testing.

Data is prepared by request, not by clicking

A test that needs a paid order should not go through the interface. It creates the state with requests — fast 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

The main point of the whole section. A test is code too, and code has bugs. A test that checks nothing is always green:

live example

def test_order(client, headers):
    client.post(f"{BASE}/orders", json={"productId": "p-01", "quantity": 1}, headers=headers)
    assert True          # this happens more often than you would like
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Free week →

There is one honest way to check your tests: break the system 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.

The technique is called mutation testing, and in the API autotest trainer it runs automatically: your tests are executed against nine broken versions of the service, and the report shows which breakages they caught.

Where to go next

When API tests come easily, the usual next steps are: running them in the build pipeline, reports and flaky-test triage, and only then interface tests — they are more expensive and more capricious.

In short

  • A test project needs two libraries: pytest and requests; shared fixtures live in conftest.py.
  • Four layers get checked: status code, meaningful fields, contract headers, and consequences in the system.
  • Rejections are checked alongside happy paths — and separately, that the rejection changed nothing.
  • Data is prepared with requests, by each test for itself.
  • A green test proves nothing until it has shown that it goes red on a broken system.