Automating API tests in code
Turn manual API checks into fast automated tests with pytest and httpx. Build a 13-test suite for the Brew & Bean coffee shop API covering happy paths, validation, business rules and status flow.
In this tutorial you will turn the manual checks you did with curl and Postman into automated API tests written in Python. You will test the Brew & Bean coffee shop API with pytest and httpx, and by the end you will have 13 tests that run in about a second and tell you immediately if someone breaks the API.
You need: Python 3.10 or newer, basic pytest knowledge (see *Unit testing in Python with pytest* earlier in this track) and a terminal. No other setup is required. If you built the coffee shop API in the Backend track, you can test that instead; the tests only depend on the API's behaviour, not on how it is built.
Why automate API tests
A manual check in Postman proves the API worked once, today. An automated test proves it every time you run it: before each commit, in a pull request, after each deployment. API tests are also fast and stable compared with browser tests, because there is no screen to wait for. That is why they sit in the middle of the test pyramid and why most QA and SDET jobs ask for them.
Every API test has the same three parts, often called Arrange, Act, Assert:
Arrange, Act, Assert
| Part | What you do | Example in this lesson |
|---|---|---|
| Arrange | Prepare data and the client | Build an order payload with 2 espressos and 1 croissant |
| Act | Send one request | api.post("/orders", json=payload) |
| Assert | Check the response (and side effects) | Status is 201 and total is 390 |
Step 1: Get an API to test
The tests call a real HTTP server, exactly as a mobile app or a frontend would. That is what makes them API tests and not unit tests. Below is a small, complete Brew & Bean API. It keeps orders in memory, which is fine for learning. It follows the shared contract: GET /menu, POST /orders, GET /orders/{id}, PATCH /orders/{id}/status. Notice that the server computes the total itself, so a customer cannot send a fake price.
from datetime import datetime, timezone
from typing import Literal
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Brew & Bean API")
MENU = [
{"id": "espresso", "name": "Espresso", "category": "coffee", "price": 120},
{"id": "cappuccino", "name": "Cappuccino", "category": "coffee", "price": 180},
{"id": "cold-brew", "name": "Cold Brew", "category": "coffee", "price": 220},
{"id": "filter-coffee", "name": "Filter Coffee", "category": "coffee", "price": 80},
{"id": "masala-chai", "name": "Masala Chai", "category": "tea", "price": 90},
{"id": "croissant", "name": "Butter Croissant", "category": "bakery", "price": 150},
{"id": "banana-bread", "name": "Banana Bread", "category": "bakery", "price": 130},
{"id": "veg-sandwich", "name": "Veg Grilled Sandwich", "category": "food", "price": 160},
]
PRICES = {m["id"]: m["price"] for m in MENU}
ORDERS: dict[str, dict] = {}
Status = Literal["received", "preparing", "ready", "picked_up", "cancelled"]
NEXT = {
"received": {"preparing", "cancelled"},
"preparing": {"ready", "cancelled"},
"ready": {"picked_up"},
"picked_up": set(),
"cancelled": set(),
}
class OrderItem(BaseModel):
item_id: str
qty: int = Field(ge=1, le=20)
class OrderIn(BaseModel):
customer_name: str = Field(min_length=1, max_length=60)
phone: str = Field(pattern=r"^[6-9]\d{9}$")
items: list[OrderItem] = Field(min_length=1)
class OrderOut(BaseModel):
id: str
customer_name: str
phone: str
items: list[OrderItem]
total: int
status: Status
created_at: str
class StatusIn(BaseModel):
status: Status
@app.get("/menu")
def get_menu():
return MENU
@app.post("/orders", status_code=201, response_model=OrderOut)
def create_order(order: OrderIn):
for line in order.items:
if line.item_id not in PRICES:
raise HTTPException(422, f"Unknown item: {line.item_id}")
record = {
"id": str(uuid4()),
**order.model_dump(),
"total": sum(PRICES[i.item_id] * i.qty for i in order.items),
"status": "received",
"created_at": datetime.now(timezone.utc).isoformat(),
}
ORDERS[record["id"]] = record
return record
@app.get("/orders/{order_id}", response_model=OrderOut)
def get_order(order_id: str):
if order_id not in ORDERS:
raise HTTPException(404, "Order not found")
return ORDERS[order_id]
@app.patch("/orders/{order_id}/status", response_model=OrderOut)
def update_status(order_id: str, body: StatusIn):
order = ORDERS.get(order_id)
if order is None:
raise HTTPException(404, "Order not found")
if body.status not in NEXT[order["status"]]:
raise HTTPException(409, f"Cannot go from {order['status']} to {body.status}")
order["status"] = body.status
return orderA tiny in-memory Brew & Bean API with validation and a status flow: received, preparing, ready, picked_up (or cancelled).
Set up
- 1Create a folder
bb-api-testsand open a terminal in it. Save the code above asmain.py. - 2Create and activate a virtual environment:
python -m venv .venv, thensource .venv/bin/activate(macOS/Linux/Git Bash) or.venv\Scripts\activate(Windows). - 3Install the packages:
pip install fastapi uvicorn httpx pytest. - 4Start the API in this terminal:
uvicorn main:app --port 8000. Leave it running. - 5Open a second terminal in the same folder (activate the virtual environment again). All test commands below go there.
- 6Open
http://127.0.0.1:8000/menuin a browser. You should see the menu as JSON.
Step 2: A shared client and test data
Every test needs an HTTP client pointed at the API. Create it once in conftest.py, the file pytest reads automatically, and share it as a fixture. A fixture is a function that prepares something for your tests; a test uses it just by naming it as a parameter.
The BASE_URL comes from an environment variable. That one small habit lets you run the same tests against your laptop, a staging server or a test deployment without editing code.
import os
import httpx
import pytest
BASE_URL = os.getenv("BASE_URL", "http://127.0.0.1:8000")
@pytest.fixture(scope="session")
def api():
with httpx.Client(base_url=BASE_URL, timeout=5.0) as client:
yield client
@pytest.fixture
def order_payload():
return {
"customer_name": "Asha",
"phone": "9876543210",
"items": [{"item_id": "espresso", "qty": 2}, {"item_id": "croissant", "qty": 1}],
}
@pytest.fixture
def created_order(api, order_payload):
response = api.post("/orders", json=order_payload)
assert response.status_code == 201
return response.json()api is one shared client. order_payload gives each test a fresh valid order. created_order places an order and hands back the response body.
Why fixtures and not a global variable?
scope="session" on the api fixture means one client (and one connection pool) for the whole run. The order_payload fixture has the default scope, so every test gets its own copy of the dictionary and can change it freely.
Step 3: Your first tests
Save this as test_orders.py. Read each test as a sentence: the function name says what must be true. Notice what each test checks: not just the status code, but the body too. A test that checks only 200 can pass while the API returns garbage.
import pytest
def test_menu_returns_items(api):
response = api.get("/menu")
assert response.status_code == 200
menu = response.json()
assert len(menu) >= 1
for item in menu:
assert {"id", "name", "category", "price"} <= item.keys()
assert item["price"] > 0
def test_create_order_computes_total_on_server(api, order_payload):
response = api.post("/orders", json=order_payload)
assert response.status_code == 201
body = response.json()
assert body["total"] == 120 * 2 + 150 # espresso x2 + croissant
assert body["status"] == "received"
assert body["id"]
def test_client_cannot_set_total(api, order_payload):
order_payload["total"] = 1
response = api.post("/orders", json=order_payload)
assert response.status_code == 201
assert response.json()["total"] == 390
def test_get_order_returns_what_was_created(api, created_order):
response = api.get(f"/orders/{created_order['id']}")
assert response.status_code == 200
assert response.json() == created_order
def test_unknown_order_is_404(api):
response = api.get("/orders/does-not-exist")
assert response.status_code == 404
@pytest.mark.parametrize(
"change",
[
{"customer_name": ""},
{"phone": "12345"},
{"items": []},
{"items": [{"item_id": "espresso", "qty": 0}]},
{"items": [{"item_id": "espresso", "qty": 21}]},
{"items": [{"item_id": "pizza", "qty": 1}]},
],
ids=["empty-name", "short-phone", "no-items", "qty-zero", "qty-too-high", "unknown-item"],
)
def test_invalid_orders_are_rejected(api, order_payload, change):
order_payload.update(change)
response = api.post("/orders", json=order_payload)
assert response.status_code == 422
def test_order_follows_status_flow(api, created_order):
url = f"/orders/{created_order['id']}/status"
for status in ["preparing", "ready", "picked_up"]:
response = api.patch(url, json={"status": status})
assert response.status_code == 200
assert response.json()["status"] == status
def test_cannot_skip_a_status(api, created_order):
response = api.patch(f"/orders/{created_order['id']}/status", json={"status": "ready"})
assert response.status_code == 409
assert api.get(f"/orders/{created_order['id']}").json()["status"] == "received"13 test results (the parametrised test runs 6 times): happy paths, a server-side rule, a 404, six invalid inputs, the status flow and an illegal status jump.
pytest -vRuns every test and prints one line per test.
test_orders.py::test_menu_returns_items PASSED
test_orders.py::test_create_order_computes_total_on_server PASSED
test_orders.py::test_client_cannot_set_total PASSED
test_orders.py::test_get_order_returns_what_was_created PASSED
test_orders.py::test_unknown_order_is_404 PASSED
test_orders.py::test_invalid_orders_are_rejected[empty-name] PASSED
test_orders.py::test_invalid_orders_are_rejected[short-phone] PASSED
test_orders.py::test_invalid_orders_are_rejected[no-items] PASSED
test_orders.py::test_invalid_orders_are_rejected[qty-zero] PASSED
test_orders.py::test_invalid_orders_are_rejected[qty-too-high] PASSED
test_orders.py::test_invalid_orders_are_rejected[unknown-item] PASSED
test_orders.py::test_order_follows_status_flow PASSED
test_orders.py::test_cannot_skip_a_status PASSED
13 passed in 0.5sWhat the important tests are doing
Server-side rules. test_client_cannot_set_total sends a total of 1 rupee and checks the server ignores it. This is a real bug class: APIs that trust a price sent by the client. Tests like this protect business rules, not just plumbing.
Read-after-write. test_get_order_returns_what_was_created creates an order, then fetches it and compares the two bodies. This catches data that was accepted but never saved, or saved wrongly.
Negative tests with `parametrize`. test_invalid_orders_are_rejected is one function that runs six times, once per bad input. Each case changes one thing in a valid payload, so when a case fails you know exactly which rule broke. The ids= give each case a readable name in the report. How to choose such inputs is covered in *Designing test cases* earlier in this track.
State transitions. test_cannot_skip_a_status checks that going from received straight to ready gives 409 Conflict, and that the order is still received afterwards. Checking that nothing changed is an easy assertion to forget.
What to assert on a response
Choosing assertions
| Check | Example | When it matters |
|---|---|---|
| Status code | assert response.status_code == 201 | Always. Be exact: 201 is not 200 |
| Body fields | assert body["total"] == 390 | Always. Check values, not just that a key exists |
| Body shape | assert {"id", "name"} <= item.keys() | When consumers depend on field names |
| Headers | response.headers["content-type"].startswith("application/json") | Content type, caching, security headers |
| Side effects | A follow-up GET returns the new data | Anything that writes |
| Error body | response.json()["detail"] mentions the cause | When clients show the message |
| Response time | response.elapsed.total_seconds() < 1 | Only as a rough guard; use a load tool for real numbers |
Volatile fields
Do not compare a whole response with == when it contains values that change on every run, such as id, created_at or a token. Assert on the stable fields, or pick the volatile ones out first. In test_get_order_returns_what_was_created the full comparison is safe only because both bodies come from the same stored order.
Keeping tests reliable
Automated tests are only useful if people trust them. Three habits keep them trustworthy:
Each test creates its own data. created_order makes a new order for each test instead of relying on an order another test made. Then tests can run in any order, one at a time or in parallel.
Tests never depend on each other. If test B only passes after test A ran, one failure will cascade into many confusing ones.
One reason to fail. A test named test_cannot_skip_a_status should fail only if that rule breaks. Long tests that check ten things make failures hard to read.
BASE_URL=https://staging.example.com pytest -qSets the variable for this one command.
$env:BASE_URL = "https://staging.example.com"
pytest -qOnly run tests against environments you own and that are meant for testing.
@pytest.fixture(scope="session")
def api():
headers = {"Authorization": f"Bearer {os.environ['API_TOKEN']}"}
with httpx.Client(base_url=BASE_URL, timeout=5.0, headers=headers) as client:
yield clientPut the header on the client once and every request carries it. Read secrets from environment variables; never write a token into a test file or commit it to Git.
Using AI to speed this up
You are an SDET who writes pytest + httpx API tests.
Write tests for this endpoint. Use a fixture called `api` (an httpx.Client with base_url set) and follow Arrange, Act, Assert. One behaviour per test, clear test names.
Endpoint: [METHOD and path, e.g. POST /orders]
Request body rules: [fields, types, limits]
Responses: [status codes and what each means]
Business rules: [e.g. total is computed by the server]
Cover: the happy path, each validation rule (use pytest.mark.parametrize), a missing resource, and one state or business rule. After the code, list any case you were unsure about.What to expect: Run the tests it writes against your real API. Any test that fails is either a bug in the API or a wrong assumption by the AI: read the failure and decide which. Delete tests that merely repeat each other, and check that every test has a real assertion on the body, not only the status code.
How this comes up in interviews
Common questions: "How would you automate API testing?", "What do you assert besides the status code?", "How do you make API tests independent?" and "How do you test an endpoint that needs a login token?". A strong answer mentions a shared client fixture, an environment variable for the base URL, per-test data, negative tests with parametrize, and checking both the response and the stored state. Many interviews also ask you to write a small test live, so practise the POST then GET pattern until it is automatic.
Try it: Extend the suite
Add three more tests to test_orders.py:
1. GET /menu contains an item with id masala-chai and price 90.
2. An order for 3 filter-coffee has a total of 240.
3. An order that has been moved through preparing, ready and picked_up cannot be changed to cancelled (expect 409).
Try it: Break the API on purpose
In main.py, change the price of espresso from 120 to 125 and restart the server. Run pytest. Which tests fail, and does the failure message make the cause obvious? Then change it back.
Review checklist for API tests
- Each test sends one request (or one short flow) and checks the body, not only the status.
- The base URL and any token come from environment variables.
- Every test creates the data it needs.
- There are negative tests for each validation rule.
- Tests that write data also read it back.
Key takeaways
- API tests call a real running server over HTTP; pytest plus httpx is a simple, popular way to write them.
- Structure each test as Arrange, Act, Assert, and assert on the body and stored state, not only the status code.
- Share one client through a fixture and read
BASE_URLfrom an environment variable so the same tests run anywhere. - Use
pytest.mark.parametrizefor many bad inputs, changing one thing at a time. - Tests must create their own data and never depend on each other.
- Be careful with volatile fields and timing assertions; they make tests flaky.
Check yourself
Finishing the quiz marks the lesson as done.
1. A test posts an order and only asserts status_code == 201. What is the biggest weakness?
2. Why does the lesson read BASE_URL from an environment variable?
3. What does pytest.mark.parametrize give you in test_invalid_orders_are_rejected?
4. test_cannot_skip_a_status also fetches the order and checks it is still received. Why?
5. Test B only passes if test A ran before it. What is the problem?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.