Project: a full test suite for a coffee shop API
Build a complete automated test suite for the Brew & Bean ordering API: test design, fixtures, parametrized boundary tests, state-transition tests and a CI pipeline. Finish with a portfolio piece you can show in QA and SDET interviews.
Goal: write a test suite for the Brew & Bean ordering API (menu, orders, status updates) that finds real bugs, runs in under a second and runs automatically on GitHub.
Finished result: a repository with main.py (the API), five test files with 36 passing tests, a pytest.ini, a requirements.txt and a GitHub Actions workflow. The tests cover happy paths, validation, boundaries, the order status state machine and the OpenAPI document.
Prerequisites: Python 3.11 or newer, Git, a GitHub account, and the earlier lessons on pytest, test design and CI. Use any free AI tool to help, but write the final tests yourself.
Stage 0: the API you are testing
To keep this project self-contained, the API lives in one file with an in-memory store. In a real project it would use a database (see *Integration tests with a real database*). Its rules, which are our requirements:
- GET /menu returns the 8 menu items.
- POST /orders needs customer_name (1 to 50 characters), phone (an Indian mobile number: 10 digits starting 6 to 9) and items (at least one; each qty from 1 to 10). The server computes total; the client can't set it. An unknown item_id gives 400. Invalid data gives 422. Success gives 201 with status received.
- GET /orders/{id} returns the order, or 404.
- PATCH /orders/{id}/status moves an order along received to preparing to ready to picked_up. An order can be cancelled only from received or preparing. Any other move gives 409.
Set up
- 1Create a folder
brew-bean-testsand open a terminal in it. - 2Create a virtual environment:
python -m venv .venv, then activate it (source .venv/bin/activateon macOS or Linux,.venv\Scripts\activateon Windows). - 3Create
requirements.txtandmain.pywith the code below, then runpip install -r requirements.txt. - 4Start the API:
uvicorn main:app --reloadand openhttp://127.0.0.1:8000/docsto try it by hand. Exploring first is part of testing.
fastapi
uvicorn
httpx
pytestFastAPI is the web framework, uvicorn runs it, httpx is needed by FastAPI's test client, pytest runs the tests.
from itertools import count
from typing import Literal
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 = {item["id"]: item["price"] for item in MENU}
# An order may move only along these edges.
TRANSITIONS = {
"received": {"preparing", "cancelled"},
"preparing": {"ready", "cancelled"},
"ready": {"picked_up"},
"picked_up": set(),
"cancelled": set(),
}
ORDERS: dict[str, dict] = {}
_ids = count(1)
class OrderItem(BaseModel):
item_id: str
qty: int = Field(ge=1, le=10)
class OrderIn(BaseModel):
customer_name: str = Field(min_length=1, max_length=50)
phone: str = Field(pattern=r"^[6-9][0-9]{9}$")
items: list[OrderItem] = Field(min_length=1)
class StatusIn(BaseModel):
status: Literal["preparing", "ready", "picked_up", "cancelled"]
def calculate_total(items: list[dict]) -> int:
"""Total in rupees. Raises KeyError for an unknown item id."""
return sum(PRICES[i["item_id"]] * i["qty"] for i in items)
def reset_state() -> None:
"""Used by tests to start every test from an empty shop."""
global _ids
ORDERS.clear()
_ids = count(1)
@app.get("/menu")
def get_menu():
return MENU
@app.post("/orders", status_code=201)
def create_order(body: OrderIn):
items = [i.model_dump() for i in body.items]
try:
total = calculate_total(items)
except KeyError as err:
raise HTTPException(status_code=400, detail=f"Unknown item: {err.args[0]}")
order_id = f"ord_{next(_ids)}"
order = {
"id": order_id,
"customer_name": body.customer_name,
"phone": body.phone,
"items": items,
"total": total,
"status": "received",
}
ORDERS[order_id] = order
return order
@app.get("/orders/{order_id}")
def get_order(order_id: str):
if order_id not in ORDERS:
raise HTTPException(status_code=404, detail="Order not found")
return ORDERS[order_id]
@app.patch("/orders/{order_id}/status")
def update_status(order_id: str, body: StatusIn):
order = ORDERS.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
if body.status not in TRANSITIONS[order["status"]]:
raise HTTPException(
status_code=409,
detail=f"Cannot move from {order['status']} to {body.status}",
)
order["status"] = body.status
return orderThe system under test. Prices and statuses match the Brew & Bean project used across Tech Tracks.
Explore first
Before you write any test, spend five minutes using /docs by hand. Try an order with quantity 0, a bad phone number, an unknown item. What would you expect to happen? Write those expectations down. That list is the start of your test plan.
Stage 1: plan the tests
Never start by typing test code. Start by listing what could go wrong, using techniques from *Designing test cases: boundaries, equivalence and negative tests*. For each requirement, ask: what is a valid case, an invalid case and a boundary?
Test plan
| Requirement | Test ideas |
|---|---|
| Quantity 1 to 10 | Boundaries: 0, 1, 10, 11. Also a text value like "two" |
| Phone: 10 digits, starts with 6 to 9 | Valid 9876543210 and 6000000000; invalid: starts with 5, 9 digits, 11 digits, letters |
| Total computed by server | Two different items; quantity above 1; client sends total: 1 and it is ignored |
| Unknown item gives 400 | An item id that is not on the menu |
| Name 1 to 50 chars | Empty name; (stretch) 50 and 51 characters |
| Status flow | Full happy path; skipping a step; reviving a cancelled order; cancelling after ready |
| 404 handling | Unknown order id on GET and on PATCH |
You are a senior QA engineer. Here are the requirements of a small ordering API:
[paste the requirements from Stage 0]
List test cases I may have missed, as a table: Scenario, Input, Expected status code. Focus on negative, boundary and state-transition cases. Do not write code yet.What to expect: Compare with your own list. Keep only cases that match the stated requirements; if the AI invents a rule (for example a maximum of 5 items per order), discard it.
Stage 2: structure and fixtures
Create a tests/ folder. Three small pieces of setup make every test shorter and more reliable.
- pytest.ini tells pytest where the tests are and puts the project root on the import path, so from main import ... works.
- A client fixture gives each test a TestClient, which calls the API in-process without starting a server.
- An autouse fixture resets the in-memory orders before every test. Tests must not depend on each other or on order.
- A make_order factory creates a valid order and lets a test override only the field it cares about. This keeps each test focused on one thing.
[pytest]
pythonpath = .
testpaths = testsimport pytest
from fastapi.testclient import TestClient
from main import app, reset_state
@pytest.fixture(autouse=True)
def clean_shop():
reset_state()
yield
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def make_order(client):
"""Factory: create an order with sensible defaults; override what a test cares about."""
def _make(**overrides):
payload = {
"customer_name": "Asha",
"phone": "9876543210",
"items": [{"item_id": "espresso", "qty": 2}],
}
payload.update(overrides)
return client.post("/orders", json=payload)
return _makeconftest.py is picked up by pytest automatically; fixtures defined here are available in every test file.
Stage 3: menu and pricing tests
Start with the simplest tests to confirm your setup works. The menu tests check structure, not just the count. The pricing tests are unit tests of calculate_total, with parametrize running one function against several rows of data.
def test_menu_returns_eight_items(client):
response = client.get("/menu")
assert response.status_code == 200
assert len(response.json()) == 8
def test_every_menu_item_has_the_expected_fields(client):
for item in client.get("/menu").json():
assert set(item) == {"id", "name", "category", "price"}
assert isinstance(item["price"], int) and item["price"] > 0
def test_menu_ids_are_unique(client):
ids = [item["id"] for item in client.get("/menu").json()]
assert len(ids) == len(set(ids))import pytest
from main import calculate_total
@pytest.mark.parametrize(
"items, expected",
[
([{"item_id": "espresso", "qty": 1}], 120),
([{"item_id": "espresso", "qty": 2}], 240),
([{"item_id": "masala-chai", "qty": 1}, {"item_id": "croissant", "qty": 1}], 240),
([{"item_id": "filter-coffee", "qty": 10}], 800),
],
)
def test_calculate_total(items, expected):
assert calculate_total(items) == expected
def test_unknown_item_raises_key_error():
with pytest.raises(KeyError):
calculate_total([{"item_id": "pizza", "qty": 1}])Four parametrized rows become four separate test results, and the last test checks that an unknown item raises KeyError.
Run and try to break it
- 1Run
pytest -v. You should see the menu and pricing tests pass. - 2Prove the tests can fail: change the espresso price in
main.pyfrom 120 to 125 and run again. Which tests fail? Change it back.
tests/test_menu.py::test_menu_returns_eight_items PASSED
tests/test_menu.py::test_every_menu_item_has_the_expected_fields PASSED
tests/test_menu.py::test_menu_ids_are_unique PASSED
tests/test_pricing.py::test_calculate_total[items0-120] PASSED
...
5 passedStage 4: order creation, validation and boundaries
Write pytest tests for POST /orders using FastAPI's TestClient. I already have a `client` fixture and a `make_order(**overrides)` factory fixture that returns the response. Rules: customer_name 1 to 50 chars; phone is 10 digits starting 6 to 9; items non-empty; qty 1 to 10; total is computed by the server; unknown item_id gives 400; invalid data gives 422. Use pytest.mark.parametrize for qty boundaries and phone numbers. Test names must describe behaviour.What to expect: Check each expected status against the rules, not against what the code returns. Look for weak asserts. Make sure there is a test that a client-supplied total is ignored.
Now the main file. Notice the patterns: boundaries are tested on both sides (0 and 1, 10 and 11); each invalid input is one parametrized row, so a failure tells you exactly which case broke; and the total is asserted as an exact number worked out by hand from the menu (2 times 180 plus 130), not copied from the API.
import pytest
def test_create_order_computes_total_on_the_server(make_order):
response = make_order(
items=[
{"item_id": "cappuccino", "qty": 2},
{"item_id": "banana-bread", "qty": 1},
]
)
assert response.status_code == 201
body = response.json()
assert body["total"] == 2 * 180 + 130
assert body["status"] == "received"
assert body["id"].startswith("ord_")
def test_client_cannot_set_the_total(client):
payload = {
"customer_name": "Asha",
"phone": "9876543210",
"items": [{"item_id": "espresso", "qty": 1}],
"total": 1,
}
response = client.post("/orders", json=payload)
assert response.status_code == 201
assert response.json()["total"] == 120
def test_created_order_can_be_fetched(client, make_order):
order_id = make_order().json()["id"]
response = client.get(f"/orders/{order_id}")
assert response.status_code == 200
assert response.json()["customer_name"] == "Asha"
def test_unknown_order_returns_404(client):
assert client.get("/orders/ord_999").status_code == 404
@pytest.mark.parametrize("qty, expected", [(0, 422), (1, 201), (10, 201), (11, 422)])
def test_quantity_boundaries(make_order, qty, expected):
response = make_order(items=[{"item_id": "espresso", "qty": qty}])
assert response.status_code == expected
@pytest.mark.parametrize(
"phone, expected",
[
("9876543210", 201),
("6000000000", 201),
("5876543210", 422),
("987654321", 422),
("98765432100", 422),
("98765abcde", 422),
],
)
def test_phone_validation(make_order, phone, expected):
assert make_order(phone=phone).status_code == expected
@pytest.mark.parametrize(
"overrides",
[
{"customer_name": ""},
{"items": []},
{"items": [{"item_id": "espresso"}]},
{"items": [{"item_id": "espresso", "qty": "two"}]},
],
)
def test_invalid_orders_are_rejected(make_order, overrides):
assert make_order(**overrides).status_code == 422
def test_unknown_menu_item_is_a_400(make_order):
response = make_order(items=[{"item_id": "pizza", "qty": 1}])
assert response.status_code == 400
assert "pizza" in response.json()["detail"]
def test_missing_body_is_rejected(client):
assert client.post("/orders").status_code == 422Nine test functions; the parametrized ones expand, so they give 20 test results in total.
Stage 5: the status state machine
The status flow is a state machine: orders may move only along certain edges. Test it by listing the allowed moves (happy path), then the most important forbidden ones (skipping, reviving, cancelling too late). You don't need every pair of states, but cover each *rule* at least once.
def set_status(client, order_id, status):
return client.patch(f"/orders/{order_id}/status", json={"status": status})
def test_happy_path_through_every_status(client, make_order):
order_id = make_order().json()["id"]
for status in ["preparing", "ready", "picked_up"]:
response = set_status(client, order_id, status)
assert response.status_code == 200
assert response.json()["status"] == status
def test_cannot_skip_a_step(client, make_order):
order_id = make_order().json()["id"]
assert set_status(client, order_id, "ready").status_code == 409
def test_cancelled_order_cannot_be_revived(client, make_order):
order_id = make_order().json()["id"]
assert set_status(client, order_id, "cancelled").status_code == 200
assert set_status(client, order_id, "preparing").status_code == 409
def test_cannot_cancel_after_ready(client, make_order):
order_id = make_order().json()["id"]
set_status(client, order_id, "preparing")
set_status(client, order_id, "ready")
assert set_status(client, order_id, "cancelled").status_code == 409
def test_unknown_status_value_is_422(client, make_order):
order_id = make_order().json()["id"]
assert set_status(client, order_id, "teleported").status_code == 422
def test_status_change_on_missing_order_is_404(client):
assert set_status(client, "ord_404", "preparing").status_code == 404
def test_status_change_is_visible_on_next_get(client, make_order):
order_id = make_order().json()["id"]
set_status(client, order_id, "preparing")
assert client.get(f"/orders/{order_id}").json()["status"] == "preparing"set_status is a small helper so the tests read like sentences. Forbidden moves must return 409 and an invalid status value 422.
Stage 6: a contract check
A contract test makes sure the API still offers what its clients expect. FastAPI generates an OpenAPI document at /openapi.json. This small test fails if someone removes or renames an endpoint. For deeper checks, see *Contract testing and OpenAPI validation*.
def test_openapi_lists_every_endpoint(client):
paths = client.get("/openapi.json").json()["paths"]
assert "get" in paths["/menu"]
assert "post" in paths["/orders"]
assert "get" in paths["/orders/{order_id}"]
assert "patch" in paths["/orders/{order_id}/status"]Run it all
- 1Run the whole suite:
pytest -v. - 2You should see all tests pass in well under a second.
- 3Run one file:
pytest tests/test_status.py -v. Run one test:pytest tests/test_status.py::test_cannot_skip_a_step. - 4Run only tests whose name contains a word:
pytest -k phone.
collected 36 items
tests/test_contract.py . [ 2%]
tests/test_menu.py ... [ 11%]
tests/test_orders.py ....................... [ 77%]
tests/test_pricing.py ..... [ 91%]
tests/test_status.py ....... [100%]
============================== 36 passed ==============================A warning that is not your bug
You may see a deprecation warning from the test client about httpx. It comes from the libraries, not from your tests. If it bothers you, check the FastAPI documentation for the currently recommended test client dependency.
Stage 7: find a bug on purpose (mutation check)
Green tests mean little unless you know they can go red. Make small deliberate bugs in main.py and confirm the suite catches each one. This is a hand-made form of mutation testing.
Mutations to try
| Mutation in main.py | Which test should fail? |
|---|---|
Change le=10 to le=11 in OrderItem | test_quantity_boundaries[11-422] |
Change the phone pattern to [5-9] | test_phone_validation[5876543210-422] |
In TRANSITIONS, allow ready to go to cancelled | test_cannot_cancel_after_ready |
Return qty only (forget the price) in calculate_total | pricing and order total tests |
Make get_order return 200 with {} for unknown ids | test_unknown_order_returns_404 |
Check yourself
- Each mutation made at least one test fail. If one did not, write the missing test.
- You reverted every mutation and the suite is green again.
- Test names tell you what broke without opening the file.
Stage 8: run it in CI
Put the project on GitHub and add the workflow below, as in *Running tests in CI with GitHub Actions*. After you push, open the Actions tab and watch the run. Then require the test check to pass before merging to main.
Publish and automate
- 1Create
.gitignorewith the lines.venv/,__pycache__/and.pytest_cache/. - 2Run
git init,git add .,git commit -m "Brew & Bean API with test suite". - 3Create an empty repository on GitHub, then follow its instructions to add the remote and push.
- 4Add
.github/workflows/tests.ymlwith the workflow below, commit and push. - 5Check the green tick in the Actions tab. Add a README with the badge and a short "how to run the tests" section.
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: pytest -v --junitxml=report.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: junit-report
path: report.xmlRuns the whole suite on every push to main and every pull request, and saves a JUnit report.
Act as a strict reviewer of an automated API test suite. Below are my requirements and my test files. Find: (1) requirements with no test, (2) tests with weak assertions, (3) tests that depend on each other or on execution order, (4) unclear test names. Give a numbered list with the file and function for each issue, and do not rewrite the whole suite.
--- requirements ---
[paste]
--- tests ---
[paste]What to expect: Verify every claim yourself by reading the test. Treat the list as prompts for thinking, not as orders.
Stretch goals
Try it: Cover the name length boundary
Add parametrized tests for customer_name of 1, 50 and 51 characters (use "a" * 50). Decide the expected status for each from the requirements before running.
Try it: Add a coverage report
Install pytest-cov and run pytest --cov=main --cov-report=term-missing. Which lines are not covered? Write a test for one of them, or explain why it does not matter.
Try it: Find a real bug
Think about what the API does when two orders are created at the same moment, or when the same order is updated twice quickly. Write down two behaviours the requirements do not define, and raise them as questions to the product owner.
Try it: Swap in a real database
Replace the in-memory dictionary with SQLite using SQLAlchemy and write a fixture that creates a fresh database for each test. Compare with *Integration tests with a real database*.
Try it: Add load and security checks
Write a k6 smoke script for GET /menu and POST /orders, and run a ZAP baseline scan against your running API. Add both to a scheduled workflow.
Key takeaways
- Plan before coding: turn each requirement into valid, invalid and boundary cases.
- Fixtures and factories (
client,make_order, an autouse reset) keep tests short and independent. - Parametrize boundaries on both sides (0, 1, 10, 11); compute expected values from the requirement by hand.
- Test state machines by covering each rule: the happy path plus the important forbidden moves.
- Prove tests can fail by breaking the code on purpose.
- Run the suite in CI on every push and require it to pass; put the repository, a README and the badge in your portfolio.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why does conftest.py have an autouse fixture that resets the orders?
2. Which pair of quantities best tests the upper boundary of 1 to 10?
3. In test_create_order_computes_total_on_the_server, why is the expected value written as 2 * 180 + 130?
4. An order is ready. A test requests cancelled and expects 409. What does this test protect?
5. You change a price in main.py and no test fails. What does that tell you?
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.