Testing an API and Writing a README
30 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A project you show to an interviewer must work on the first try. Tests prove that each route still does its job after you change code. A README tells a stranger what the project is and how to run it. Together they make a project look finished.
TestClient calls your API without a server
TestClient is a FastAPI tool that sends requests straight to your app in memory, with no server and no network. It returns responses with status_code and json(), so a test can check them with assert.
You do not need to start uvicorn to test. TestClient wraps your app and sends requests to it in memory. You get a normal response object with a status code and JSON, and you check them with assert.
from fastapi.testclient import TestClientfrom main import app
client = TestClient(app)
def test_health(): response = client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"}Install the tools with python -m pip install pytest httpx. Run every test with python -m pytest -q. pytest finds files named test_*.py and functions named test_*.
A fresh database for every test
A fixture is a function marked with @pytest.fixture that prepares something a test needs, such as an empty database. Name it as a test argument and pytest runs it first and hands over the result.
A test must never touch your real data file, and one test must not leave rows for the next. A fixture builds an empty in-memory database and swaps it in for get_db before each test.
@pytest.fixture()def client(): engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(engine) TestSession = sessionmaker(engine)
def override_get_db(): with TestSession() as db: yield db
app.dependency_overrides[get_db] = override_get_db yield TestClient(app) app.dependency_overrides.clear()Example 1 · A test file with its own database
import pytestfrom fastapi.testclient import TestClientfrom sqlalchemy import create_enginefrom sqlalchemy.orm import sessionmakerfrom sqlalchemy.pool import StaticPool
from main import Base, app, get_db
@pytest.fixture()def client(): engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(engine) TestSession = sessionmaker(engine)
def override_get_db(): with TestSession() as db: yield db
app.dependency_overrides[get_db] = override_get_db yield TestClient(app) app.dependency_overrides.clear()
def test_create_ticket(client): response = client.post("/tickets", json={"title": "Login page broken"}) assert response.status_code == 201 assert response.json() == {"id": 1, "title": "Login page broken", "status": "open"}
def test_short_title_is_rejected(client): response = client.post("/tickets", json={"title": "Hi"}) assert response.status_code == 422
def test_missing_ticket_gives_404(client): assert client.get("/tickets/42").status_code == 404... [100%] 3 passed in 1.77s
What this code does
- This file sits next to
main.pyfrom the last lesson. Theclientfixture builds a new empty database for each test. - The ticket gets id 1 in every test, because no earlier test left a row behind.
- Each dot is a passed test.
-qkeeps the report short.
Any test that has a client argument gets a new client with an empty database. dependency_overrides replaces a dependency only while the test runs. StaticPool makes every session share the one in-memory database.
Test the failures too
The happy path is half the job. Write one test for each way a request can go wrong: invalid data, a missing record and a missing login. These are the bugs users meet first.
def test_short_title_is_rejected(client): response = client.post("/tickets", json={"title": "Hi"}) assert response.status_code == 422
def test_missing_ticket_gives_404(client): assert client.get("/tickets/42").status_code == 404
def test_me_needs_a_token(client): assert client.get("/me").status_code == 401Example 2 · Reading a failing test
Runs in: Your terminal
python -m pytest -qF.. [100%]
================================== FAILURES ===================================
_____________________________ test_create_ticket ______________________________
client = <starlette.testclient.TestClient object at 0x0000011CC685E3C0>
def test_create_ticket(client):
response = client.post("/tickets", json={"title": "Login page broken"})
> assert response.status_code == 200
E assert 201 == 200
E + where 201 = <Response [201 Created]>.status_code
test_tickets.py:27: AssertionError
=========================== short test summary info ===========================
FAILED test_tickets.py::test_create_ticket - assert 201 == 200
1 failed, 2 passed in 1.97sWhat this code does
- The
Fshows which test failed. The line starting with>points at the assert that broke. assert 201 == 200says the route answered 201 but the test expected 200. Here the test is wrong, because a created ticket should say 201.- Read the lines from the bottom: the summary line names the test, and the lines above show the values.
Keep each test small: one request and one or two asserts. A good name, such as test_missing_ticket_gives_404, tells you what broke when it fails. For protected routes, log in inside the test and send the Authorization header.
A README that lets anyone run it
A README is the front page of a project, usually README.md. It says what the project does, how to install and run it, how to test it and what routes it offers, so a stranger can use it without asking.
The README is the first file a reader opens. Say what the project does, list its features and routes, and give the exact commands to install, run and test it. Test your own instructions on a clean folder.
# Ticket Desk APIA REST API for support tickets, built with FastAPI and SQLite.## Features- Sign up and log in with JWT; roles: learner and admin- Create, list and close tickets## Run itpython -m venv .venvpython -m pip install -r requirements.txtpython -m uvicorn main:app --reload## Testspython -m pytest -qExample 3 · Checking a README with code
REQUIRED = ["Features", "Run it", "API routes", "Tests"]readme = """# Ticket Desk APIA small REST API for support tickets.
## FeaturesSign up, log in with a token, create and close tickets.
## Run itpython -m uvicorn main:app --reload
## Testspython -m pytest -q"""
headings = [line.lstrip("#").strip() for line in readme.splitlines() if line.startswith("##")]print("found:", headings)print("missing:", [name for name in REQUIRED if name not in headings])found: ['Features', 'Run it', 'Tests'] missing: ['API routes']
What this code does
- A README is plain text, so a short script can check it. Headings are the lines that start with
##. lstrip("#")removes the hash marks andstrip()removes the spaces.- The list comprehension at the end keeps the required names that are not found.
API routesis missing.
Add requirements.txt with python -m pip freeze > requirements.txt, and a .gitignore that lists .venv, __pycache__ and *.db. Never commit secrets. Show the /docs address and one sample request, so a reviewer can try it in a minute.
Next step
Check your understanding
10 quick questions