All posts

Hands-On Testing · Part 4

Your first API tests in 15 minutes (Python, pytest and requests)

ProvaLab team · · 8 min read

Part 3 explained what to check in an API response. Now we check it. Rather than test someone else's API, which you may not have permission to hit, we'll build a tiny one of our own in about forty lines, then write a proper test suite against it: functional, contract, negative, authentication and authorisation tests.

Every command and every line of output below was run while writing this post.

What you need: Python 3.10 or newer and a terminal. Nothing to sign up for.

Step 1: Set up (2 minutes)

mkdir first-api-test
cd first-api-test
python3 -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\Activate.ps1
pip install fastapi uvicorn requests pytest

FastAPI builds the API, uvicorn runs it, requests calls it, pytest runs the tests.

Step 2: Build the API (5 minutes)

Create app.py. It's the discount feature from our food delivery app, as an API:

from datetime import date

from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title="Discount API")

# Who is who: a bearer token maps to a customer (a real API would check a database)
TOKENS = {"token-alice": "alice", "token-bob": "bob"}

# Discount codes: percentage off and the day they expire
CODES = {
    "SAVE10": {"percent": 10, "expires": date(2099, 1, 1)},
    "OLD10": {"percent": 10, "expires": date(2025, 11, 1)},
}

# Orders and who owns them
ORDERS = {42: {"customer": "alice", "total": 500}, 43: {"customer": "bob", "total": 300}}


def current_customer(authorization: str = Header(default="")) -> str:
    token = authorization.removeprefix("Bearer ").strip()
    if token not in TOKENS:
        raise HTTPException(status_code=401, detail="sign in first")
    return TOKENS[token]


class ApplyDiscount(BaseModel):
    order_total: float = Field(gt=0)
    code: str


@app.post("/discounts/apply")
def apply_discount(body: ApplyDiscount, customer: str = Depends(current_customer)):
    code = CODES.get(body.code.upper())
    if code is None:
        raise HTTPException(status_code=404, detail="code not recognised")
    if code["expires"] < date.today():
        raise HTTPException(status_code=400, detail="this code has expired")
    discount = body.order_total * code["percent"] / 100
    return {"total": body.order_total - discount, "discount": discount, "code": body.code.upper()}


@app.get("/orders/{order_id}")
def get_order(order_id: int, customer: str = Depends(current_customer)):
    order = ORDERS.get(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="order not found")
    if order["customer"] != customer:
        raise HTTPException(status_code=403, detail="not your order")
    return {"id": order_id, **order}

Two endpoints:

  • POST /discounts/apply takes an order total and a code, and returns the discounted total. Unknown codes get 404, expired ones 400, and a total of zero or less is rejected with 422 (FastAPI validates the body for us, because of Field(gt=0)).
  • GET /orders/{id} returns an order, but only to the customer who owns it: 401 with no valid token, 403 for someone else's order, 404 if it doesn't exist.

Start it in a terminal and leave it running:

uvicorn app:app

Then make one request by hand, to see what a response looks like:

curl -i -X POST http://127.0.0.1:8000/discounts/apply \
  -H "Authorization: Bearer token-alice" \
  -H "Content-Type: application/json" \
  -d '{"order_total": 500, "code": "SAVE10"}'
HTTP/1.1 200 OK
content-length: 47
content-type: application/json

{"total":450.0,"discount":50.0,"code":"SAVE10"}

Status, headers, body: the three things from Part 3, in that order.

Step 3: Fixtures, so every test doesn't repeat itself (2 minutes)

Open a second terminal in the same folder, with the virtual environment activated. Create conftest.py, where pytest looks for shared setup:

import os

import pytest
import requests

BASE_URL = os.environ.get("API_URL", "http://127.0.0.1:8000")


@pytest.fixture
def api():
    """A session signed in as Alice. Every request through it carries her token."""
    session = requests.Session()
    session.headers["Authorization"] = "Bearer token-alice"
    session.base = BASE_URL
    yield session
    session.close()


@pytest.fixture
def anonymous():
    """A session with no token at all."""
    session = requests.Session()
    session.base = BASE_URL
    yield session
    session.close()

A fixture is setup that a test asks for by name. api gives a session already signed in as Alice; anonymous gives one with no token. The address comes from an environment variable, so the same tests can later point at a staging server: API_URL=https://staging.example.com pytest.

Step 4: The discount tests (3 minutes)

Create test_discounts.py:

def test_valid_code_reduces_the_total(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500, "code": "SAVE10"})
    assert r.status_code == 200
    assert r.json() == {"total": 450, "discount": 50, "code": "SAVE10"}


def test_response_matches_the_contract(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500, "code": "SAVE10"})
    body = r.json()
    assert r.headers["Content-Type"] == "application/json"
    assert set(body) == {"total", "discount", "code"}
    assert isinstance(body["total"], (int, float))
    assert isinstance(body["discount"], (int, float))
    assert isinstance(body["code"], str)
    assert r.elapsed.total_seconds() < 0.5


def test_code_is_not_case_sensitive(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500, "code": "save10"})
    assert r.status_code == 200
    assert r.json()["total"] == 450


def test_unknown_code_is_refused(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500, "code": "NOPE99"})
    assert r.status_code == 404
    assert r.json()["detail"] == "code not recognised"


def test_expired_code_is_refused(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500, "code": "OLD10"})
    assert r.status_code == 400
    assert "expired" in r.json()["detail"]


def test_zero_or_negative_total_is_rejected(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 0, "code": "SAVE10"})
    assert r.status_code == 422


def test_missing_field_is_rejected(api):
    r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500})
    assert r.status_code == 422

Notice how the five checks from Part 3 show up:

  • Status code in every test, and the exact one: 404 for an unknown code, 400 for an expired one, 422 for bad input. Not "some 4xx".
  • Headers and body shape in test_response_matches_the_contract: the content type, exactly the three promised fields, the right types. This is the test that catches a renamed field before it breaks the mobile app.
  • Values in the first test: 500 with SAVE10 is 450, full stop.
  • Timing with r.elapsed: under half a second.
  • Negative cases are four of the seven tests. That's roughly the right proportion.

Step 5: The authorisation tests (2 minutes)

Create test_auth.py:

def test_no_token_means_401(anonymous):
    r = anonymous.post(f"{anonymous.base}/discounts/apply", json={"order_total": 500, "code": "SAVE10"})
    assert r.status_code == 401


def test_customer_can_read_their_own_order(api):
    r = api.get(f"{api.base}/orders/42")
    assert r.status_code == 200
    assert r.json()["customer"] == "alice"


def test_customer_cannot_read_someone_elses_order(api):
    r = api.get(f"{api.base}/orders/43")  # Bob's order, Alice's token
    assert r.status_code == 403


def test_unknown_order_is_404(api):
    r = api.get(f"{api.base}/orders/999")
    assert r.status_code == 404

Four requests for GET /orders/43 and their correct answers. No token: 401 Unauthorized, authentication failed. Alice's token for Bob's order: 403 Forbidden, authorisation failed, the bug to look for. Bob's token: 200 with the order. Any token for order 999: 404 Not Found. Assert the exact code: a test accepting any 4xx would miss an API that returns 404 instead of 403.

test_customer_cannot_read_someone_elses_order is the most valuable test in this post. Signing in as one customer and asking for another's data is the single most common serious API security bug, and it takes four lines to check. Note the exact code: 403, not "any error". An API that answered 404 here would be quietly telling an attacker that order 43 exists.

Step 6: Run it (1 minute)

pytest -v
collected 11 items

test_auth.py::test_no_token_means_401 PASSED                             [  9%]
test_auth.py::test_customer_can_read_their_own_order PASSED              [ 18%]
test_auth.py::test_customer_cannot_read_someone_elses_order PASSED       [ 27%]
test_auth.py::test_unknown_order_is_404 PASSED                           [ 36%]
test_discounts.py::test_valid_code_reduces_the_total PASSED              [ 45%]
test_discounts.py::test_response_matches_the_contract PASSED             [ 54%]
test_discounts.py::test_code_is_not_case_sensitive PASSED                [ 63%]
test_discounts.py::test_unknown_code_is_refused PASSED                   [ 72%]
test_discounts.py::test_expired_code_is_refused PASSED                   [ 81%]
test_discounts.py::test_zero_or_negative_total_is_rejected PASSED        [ 90%]
test_discounts.py::test_missing_field_is_rejected PASSED                 [100%]

============================== 11 passed in 0.08s ==============================

Eleven tests, 0.08 seconds. The three browser tests in Part 2 took 10.68 seconds. That's the speed difference from Part 3 made concrete: about a hundred times faster per test.

The eleven tests grouped by kind. Functional, three: valid code reduces the total, code is not case-sensitive, customer can read their own order. Contract, one: exact fields, right types, under 500 ms. Negative, four: unknown code 404, expired 400, zero total 422, missing field 422. Auth, three: no token 401, someone else's order 403, unknown order 404. All in 0.08 seconds. The planted bug broke exactly the two that check values.

Step 7: Plant a bug (2 minutes)

Change one line in app.py so the discount is subtracted as a flat amount instead of a percentage:

    discount = code["percent"]  # bug: flat amount, not a percentage

Restart uvicorn and run the tests again:

....F.F....                                                              [100%]
=================================== FAILURES ===================================
______________________ test_valid_code_reduces_the_total _______________________

    def test_valid_code_reduces_the_total(api):
        r = api.post(f"{api.base}/discounts/apply", json={"order_total": 500, "code": "SAVE10"})
        assert r.status_code == 200
>       assert r.json() == {"total": 450, "discount": 50, "code": "SAVE10"}
E       AssertionError: assert {'total': 490...de': 'SAVE10'} == {'total': 450...de': 'SAVE10'}
E         Differing items:
E         {'discount': 10} != {'discount': 50}
E         {'total': 490.0} != {'total': 450}

test_discounts.py:4: AssertionError
_______________________ test_code_is_not_case_sensitive ________________________
>       assert r.json()["total"] == 450
E       assert 490.0 == 450

test_discounts.py:21: AssertionError
========================= short test summary info ============================
FAILED test_discounts.py::test_valid_code_reduces_the_total - AssertionError:...
FAILED test_discounts.py::test_code_is_not_case_sensitive - assert 490.0 == 450
2 failed, 9 passed in 0.06s

Exactly the two tests that check values fail; the nine that check status codes, shape and permissions still pass, because the bug didn't touch those. pytest's diff says precisely what's wrong: discount 10 != 50. Put the line back, and you're green again.

What this suite doesn't cover, and what to add next

  • A sequence: create an order, apply a code, read it back. That's integration testing through the API.
  • Contract from a document: FastAPI generates an OpenAPI document at /openapi.json; tools like Schemathesis can generate hundreds of tests from it.
  • Load: the same endpoints under 200 users at once, as described in performance testing types; we build one with k6 later in this series.
  • Running in CI: the same pytest step as in Part 2's workflow, with uvicorn app:app & started first, or a test server fixture.

Habits worth keeping

  1. Assert the exact status code. The difference between 401, 403 and 404 is the whole point.
  2. Test the contract separately from the values. Shape changes break clients even when the maths is right.
  3. Make negative tests at least a third of the suite. That's where the bugs live.
  4. Always test "someone else's data". One test, four lines, the most common serious API bug.
  5. Never put real tokens in the code. Read them from the environment; use test accounts.

What's next

In Part 5 we move to security testing: the OWASP Top 10 in plain language, and which of those risks the tests you just wrote already touch.

ProvaLab's QA AI Agent runs API test cases like these on its own and records every request and response against its test case; AppSec's API fuzzing sends thousands of malformed and unauthorised requests to find the 403-that-should-have-been and the 500-that-should-have-been-a-400. See it in the two-minute product tour.

Prefer to read offline? The whole Hands-On Testing series is one free PDF: get the PDF.

See ProvaLab working on a real app

Your coding agent runs the tests, reads the failures and fixes them.