Chapter 5 · Project and Next Steps
Project, Part 2: Test, Document and Publish It
- Page 21 of 22
- 32 min read
The Data Explorer API works on your machine, on a branch, checked by hand. That is not yet something a team can rely on. On this page you finish the job the way it is done at work: automated tests that run in a second without the Practice API, a README that lets a stranger run the project, a tidy .gitignore, a pull request with a review, a refactor and a bug fix that come out of that review, and finally the merge into main and a check that a fresh clone really works.
Every AI product with real users goes through this loop many times a week. It matters even more when the upstream is an LLM: real calls are slow, cost money and give different answers each time, so the only way to test your own logic reliably is to replace the provider with a fake that answers instantly and always the same way.
What you will learn
- How to test an API that depends on another API: fake the upstream with
monkeypatchat two levels, the client functions and the HTTP session. - How to test failure paths (timeouts, connection errors, bad data) that are hard to trigger for real.
- How to write a README that answers "what is this, how do I run it, how do I test it".
- How a pull request flows: push the branch, describe it, review it against a checklist, push fixes, merge, delete the branch.
- How to refactor safely, fix a bug test-first, and prove the result with a fresh clone.
Where Part 1 ended
If you are continuing from Project, Part 1: Build a Data Explorer API in the same terminal, you already have all of this: just cd ~/projects/data-explorer and skip to the git log command below. If you are starting fresh (a new computer, or you deleted the folder), the next blocks rebuild the project exactly as Part 1 left it: the same files, the same commits on the same branch. First the folder:
mkdir -p ~/projects/data-explorer/app
cd ~/projects/data-explorerThen the four files from Part 1, unchanged. Save each one under the name in its first line:
# file: .gitignore
# Python
__pycache__/
*.pyc
# Virtual environment (rebuilt from uv.lock with `uv sync`)
.venv/
# Secrets and local settings
.env
# Logs from local runs
*.log# file: app/schemas.py
"""The shapes of the data this API sends out and accepts from upstream."""
from typing import Literal
from pydantic import BaseModel, Field
Level = Literal["beginner", "intermediate", "advanced"]
class Course(BaseModel):
id: int = Field(ge=1)
title: str = Field(min_length=1)
level: Level
hours: int = Field(ge=0)
rating: float = Field(ge=0, le=5)
class CourseList(BaseModel):
count: int
items: list[Course]
class LevelSummary(BaseModel):
level: Level
courses: int
total_hours: int
average_rating: float
class UpstreamWeather(BaseModel):
"""What the Practice API sends for /v1/weather (its names, not ours)."""
city: str
temp_c: float
humidity: int
condition: str
class Weather(BaseModel):
city: str
temperature_c: float
humidity_pct: int = Field(ge=0, le=100)
condition: str
advice: str# file: app/upstream.py
"""Everything that talks to the BitByte Practice API lives in this one file."""
import os
import requests
from pydantic import BaseModel, ValidationError
from app.schemas import Course, UpstreamWeather
BASE_URL = os.environ.get("PRACTICE_API_URL", "http://localhost:8800")
TIMEOUT_SECONDS = float(os.environ.get("UPSTREAM_TIMEOUT", "5"))
MAX_PAGES = 50
session = requests.Session()
session.headers["User-Agent"] = "bitbyte-data-explorer/0.1"
class UpstreamError(Exception):
"""The Practice API was unreachable, too slow, or sent something we cannot use."""
def __init__(self, message: str, status_code: int = 502):
super().__init__(message)
self.message = message
self.status_code = status_code
def get_json(path: str, params: dict | None = None) -> dict | None:
"""GET a Practice API path and return the JSON body, or None for a 404."""
try:
response = session.get(f"{BASE_URL}{path}", params=params, timeout=TIMEOUT_SECONDS)
except requests.Timeout:
raise UpstreamError(f"Practice API did not answer within {TIMEOUT_SECONDS:g} s", 504)
except requests.ConnectionError:
raise UpstreamError("Practice API is unreachable")
if response.status_code == 404:
return None
if not response.ok:
raise UpstreamError(f"Practice API failed with status {response.status_code}")
try:
return response.json()
except ValueError:
raise UpstreamError("Practice API sent a body that is not JSON")
def parse(model: type[BaseModel], data: dict):
"""Validate upstream data before we trust it."""
try:
return model.model_validate(data)
except ValidationError:
raise UpstreamError(f"Practice API sent {model.__name__} data in an unexpected shape")
def fetch_courses(level: str | None = None) -> list[Course]:
"""All courses (optionally of one level), following next_page to the end."""
courses, page = [], 1
while page is not None:
if page > MAX_PAGES:
raise UpstreamError("Practice API returned too many pages")
data = get_json("/v1/courses", {"level": level, "page": page, "per_page": 20})
if data is None:
raise UpstreamError("Practice API has no /v1/courses endpoint")
courses += [parse(Course, item) for item in data["items"]]
page = data["next_page"]
return courses
def fetch_course(course_id: int) -> Course | None:
data = get_json(f"/v1/courses/{course_id}")
return None if data is None else parse(Course, data)
def fetch_weather(city: str) -> UpstreamWeather | None:
data = get_json("/v1/weather", {"city": city})
return None if data is None else parse(UpstreamWeather, data)# file: app/main.py
"""BitByte Data Explorer API: clean, validated endpoints over the BitByte Practice API."""
from typing import Annotated, Literal
from fastapi import FastAPI, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse
from app import upstream
from app.schemas import Course, CourseList, Level, LevelSummary, Weather
app = FastAPI(title="BitByte Data Explorer API", version="0.1.0")
@app.exception_handler(upstream.UpstreamError)
def upstream_failed(request: Request, exc: upstream.UpstreamError) -> JSONResponse:
return JSONResponse({"detail": exc.message}, status_code=exc.status_code)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/courses")
def list_courses(
level: Level | None = None,
min_rating: Annotated[float, Query(ge=0, le=5)] = 0,
sort: Literal["id", "rating", "hours"] = "id",
) -> CourseList:
courses = [c for c in upstream.fetch_courses(level) if c.rating >= min_rating]
courses.sort(key=lambda c: getattr(c, sort), reverse=sort != "id")
return CourseList(count=len(courses), items=courses)
@app.get("/courses/{course_id}")
def get_course(course_id: Annotated[int, Path(ge=1)]) -> Course:
course = upstream.fetch_course(course_id)
if course is None:
raise HTTPException(404, f"Course {course_id} not found")
return course
@app.get("/summary")
def summary() -> list[LevelSummary]:
by_level: dict[str, list[Course]] = {}
for course in upstream.fetch_courses():
by_level.setdefault(course.level, []).append(course)
return [
LevelSummary(
level=level,
courses=len(group),
total_hours=sum(c.hours for c in group),
average_rating=round(sum(c.rating for c in group) / len(group), 2),
)
for level, group in by_level.items()
]
def advice_for(temp_c: float, condition: str) -> str:
if condition == "Rain":
return "Carry an umbrella."
if temp_c >= 30:
return "Hot day: drink water and avoid the midday sun."
return "Good weather to go out."
@app.get("/weather/{city}")
def weather(city: Annotated[str, Path(min_length=2, max_length=40)]) -> Weather:
data = upstream.fetch_weather(city)
if data is None:
raise HTTPException(404, f"No weather data for {city}")
return Weather(
city=data.city,
temperature_c=data.temp_c,
humidity_pct=data.humidity,
condition=data.condition,
advice=advice_for(data.temp_c, data.condition),
)Then the repository, the uv project and the four commits:
git init -q
uv init -q --bare --name data-explorer
uv add -q "fastapi[standard]" requests
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOF
touch app/__init__.py
git add .gitignore pyproject.toml uv.lock app/__init__.py
git commit -q -m "Set up the Data Explorer project with uv"
git switch -q -c feature/data-explorer
git add app/schemas.py
git commit -q -m "Add response and upstream schemas"
git add app/upstream.py
git commit -q -m "Add Practice API client with timeouts, pagination and validation"
git add app/main.py
git commit -q -m "Add courses, summary and weather endpoints"Either way, you should now be here:
git log --oneline --graph --decorate --all* 3a8f75a (HEAD -> feature/data-explorer) Add courses, summary and weather endpoints
* 089f152 Add Practice API client with timeouts, pagination and validation
* 6bba991 Add response and upstream schemas
* 04b26f9 (main) Set up the Data Explorer project with uvStep 1: A test plan
The app has two layers, so the tests have two layers. Each one replaces what is below it with a fake:
| Test file | What it tests | What is faked | What it proves |
|---|---|---|---|
tests/test_api.py | the routes in app/main.py, through TestClient | upstream.fetch_courses, fetch_course, fetch_weather | filters, sorting, maths, reshaping, status codes, error format |
tests/test_upstream.py | the client in app/upstream.py | upstream.session.get (the HTTP call itself) | pagination, timeout on every call, every failure becomes the right UpstreamError |
No test talks to the real Practice API. That is deliberate. Tests that call a live service are slow, fail when the network or the service has a bad day, and can only test the answers the service happens to give today. With fakes you choose the answer: a timeout, a 503, a body that is not JSON. Checking the real connection is a different, smaller job (the smoke test at the end of this page).
Step 2: Test dependencies and settings
pytest runs the tests; httpx2 is what FastAPI's TestClient uses to make requests (see Testing Your API with pytest for why httpx2 and not httpx). They are only needed during development, so they go in uv's dev group, which is not installed in production images:
uv add -q --dev pytest httpx2
cat >> pyproject.toml <<'EOF'
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
EOF
mkdir -p tests
cat pyproject.toml[project]
name = "data-explorer"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.143.0",
"requests>=2.34.2",
]
[tool.fastapi]
entrypoint = "app.main:app"
[dependency-groups]
dev = [
"httpx2>=2.13.1",
"pytest>=9.1.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]pythonpath = ["."] lets the files in tests/ import app from the project folder; testpaths tells pytest where to look. With pip, the equivalent is pip install pytest httpx2 plus a requirements-dev.txt file listing them.
Step 3: Fixtures: a client and a fake upstream
A conftest.py file holds fixtures that every test file can use. The fake upstream returns four made-up courses, deliberately different from the Practice API's data, and records which calls were made:
# file: tests/conftest.py
"""Shared fixtures: a test client and a fake upstream with fixed data."""
import pytest
from fastapi.testclient import TestClient
from app import upstream
from app.main import app
from app.schemas import Course, UpstreamWeather
FAKE_COURSES = [
Course(id=1, title="Intro to AI", level="beginner", hours=4, rating=4.0),
Course(id=2, title="Agents", level="advanced", hours=10, rating=4.9),
Course(id=3, title="Python", level="beginner", hours=12, rating=4.6),
Course(id=4, title="Prompting", level="intermediate", hours=5, rating=4.4),
]
FAKE_WEATHER = {
"dhaka": UpstreamWeather(city="Dhaka", temp_c=31.5, humidity=78, condition="Humid"),
"sylhet": UpstreamWeather(city="Sylhet", temp_c=27.4, humidity=85, condition="Rain"),
}
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def fake_courses():
return FAKE_COURSES
@pytest.fixture
def fake_upstream(monkeypatch):
"""Replace the upstream functions with fakes that never touch the network."""
calls = []
def fake_fetch_courses(level=None):
calls.append(("courses", level))
return [c for c in FAKE_COURSES if level is None or c.level == level]
def fake_fetch_course(course_id):
calls.append(("course", course_id))
return next((c for c in FAKE_COURSES if c.id == course_id), None)
def fake_fetch_weather(city):
calls.append(("weather", city))
return FAKE_WEATHER.get(city.lower())
monkeypatch.setattr(upstream, "fetch_courses", fake_fetch_courses)
monkeypatch.setattr(upstream, "fetch_course", fake_fetch_course)
monkeypatch.setattr(upstream, "fetch_weather", fake_fetch_weather)
return callsmonkeypatch.setattr(upstream, "fetch_courses", ...) replaces the function inside the upstream module for the duration of one test, and puts the original back afterwards. It works because app/main.py calls upstream.fetch_courses(...) through the module at request time. Had it used from app.upstream import fetch_courses, main would hold its own reference to the real function and the patch would miss it: patch the name where it is looked up, not where it is defined.
Step 4: Testing the endpoints
# file: tests/test_api.py
"""Endpoint tests. The upstream is replaced by fakes (see conftest.py)."""
from app import upstream
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_courses_sorted_by_rating(client, fake_upstream):
response = client.get("/courses", params={"sort": "rating"})
assert response.status_code == 200
body = response.json()
assert body["count"] == 4
assert [c["id"] for c in body["items"]] == [2, 3, 4, 1]
def test_courses_filters(client, fake_upstream):
body = client.get("/courses", params={"level": "beginner", "min_rating": 4.5}).json()
assert [c["title"] for c in body["items"]] == ["Python"]
assert fake_upstream == [("courses", "beginner")]
def test_bad_parameters_never_reach_upstream(client, fake_upstream):
assert client.get("/courses", params={"min_rating": 6}).status_code == 422
assert client.get("/courses", params={"sort": "price"}).status_code == 422
assert client.get("/courses/0").status_code == 422
assert fake_upstream == []
def test_course_found_and_missing(client, fake_upstream):
assert client.get("/courses/2").json()["title"] == "Agents"
missing = client.get("/courses/99")
assert missing.status_code == 404
assert missing.json() == {"detail": "Course 99 not found"}
def test_summary_numbers(client, fake_upstream):
rows = {row["level"]: row for row in client.get("/summary").json()}
assert rows["beginner"] == {
"level": "beginner", "courses": 2, "total_hours": 16, "average_rating": 4.3,
}
def test_weather_is_reshaped(client, fake_upstream):
assert client.get("/weather/Sylhet").json() == {
"city": "Sylhet", "temperature_c": 27.4, "humidity_pct": 85,
"condition": "Rain", "advice": "Carry an umbrella.",
}
assert client.get("/weather/Comilla").status_code == 404
def test_upstream_failure_becomes_502(client, monkeypatch):
def broken(level=None):
raise upstream.UpstreamError("Practice API is unreachable")
monkeypatch.setattr(upstream, "fetch_courses", broken)
response = client.get("/courses")
assert response.status_code == 502
assert response.json() == {"detail": "Practice API is unreachable"}What to notice:
- The expected values are worked out by hand from the fake data, not copied from the API's output: the two beginner courses have 4 + 12 = 16 hours and an average rating of (4.0 + 4.6) / 2 = 4.3. A test that copies whatever the code returns only proves that the code returns what it returns.
fake_upstreamis also a spy.test_courses_filterschecks that thelevelfilter is sent to the upstream (less data over the wire), andtest_bad_parameters_never_reach_upstreamchecks that bad input is rejected before any upstream call, which matters when every upstream call costs money, as LLM calls do.- The failure test needs no network trick: a fake that raises
UpstreamErroris enough to prove the exception handler turns it into a 502 with our message.
Step 5: Testing the client
Now one level down. Here the fake replaces upstream.session.get, the single place where a request would leave the machine, so the real get_json, parse and fetch_courses code runs:
# file: tests/test_upstream.py
"""Client tests. session.get is replaced, so no request leaves the machine."""
import pytest
import requests
from app import upstream
class FakeResponse:
"""Just enough of requests.Response for app/upstream.py."""
def __init__(self, status_code=200, data=None):
self.status_code = status_code
self.ok = status_code < 400
self._data = data
def json(self):
if self._data is None:
raise ValueError("not JSON")
return self._data
def course(course_id):
return {"id": course_id, "title": f"Course {course_id}", "level": "beginner",
"hours": 2, "rating": 4.5}
def test_fetch_courses_follows_pages_with_a_timeout(monkeypatch):
pages = {
1: {"items": [course(1), course(2)], "next_page": 2},
2: {"items": [course(3)], "next_page": None},
}
seen = []
def fake_get(url, params=None, timeout=None):
seen.append((params["page"], timeout))
return FakeResponse(200, pages[params["page"]])
monkeypatch.setattr(upstream.session, "get", fake_get)
assert [c.id for c in upstream.fetch_courses()] == [1, 2, 3]
assert seen == [(1, upstream.TIMEOUT_SECONDS), (2, upstream.TIMEOUT_SECONDS)]
@pytest.mark.parametrize(
("error", "status"),
[(requests.Timeout(), 504), (requests.ConnectionError(), 502)],
)
def test_network_errors(monkeypatch, error, status):
def fake_get(url, params=None, timeout=None):
raise error
monkeypatch.setattr(upstream.session, "get", fake_get)
with pytest.raises(upstream.UpstreamError) as caught:
upstream.fetch_course(1)
assert caught.value.status_code == status
@pytest.mark.parametrize(
"response",
[FakeResponse(503, {"detail": "down"}), FakeResponse(200, None), FakeResponse(200, {"id": 1})],
ids=["status-503", "not-json", "wrong-shape"],
)
def test_bad_answers_become_502(monkeypatch, response):
monkeypatch.setattr(upstream.session, "get", lambda *args, **kwargs: response)
with pytest.raises(upstream.UpstreamError) as caught:
upstream.fetch_course(1)
assert caught.value.status_code == 502
def test_404_means_none(monkeypatch):
monkeypatch.setattr(upstream.session, "get", lambda *args, **kwargs: FakeResponse(404, {}))
assert upstream.fetch_course(99) is NoneFakeResponse has only the three things get_json uses: status_code, ok and json(). @pytest.mark.parametrize runs one test function several times with different inputs, so the five failure cases need two short functions instead of five copies. The first test also checks that every call carried the timeout: a requirement from the brief, now enforced forever.
Run everything. To prove no test needs the Practice API, point the app at a port where nothing listens:
PRACTICE_API_URL=http://127.0.0.1:9 uv run pytest -q............... [100%]
15 passed in 0.05s15 tests (the parametrized ones count once per case), all green, in well under a second. Commit them with the settings and the updated lock file:
git add pyproject.toml uv.lock tests/
git commit -q -m "Add endpoint and client tests with a faked upstream"Step 6: The README
A README answers, in this order: what is this, how do I run it, how do I use it, how do I test it, and what can I configure (see Pull Requests, README and Your First Published Project). Examples beat descriptions: a reader should be able to copy a command and see the documented result.
cat > README.md <<'EOF'
# BitByte Data Explorer API
A small FastAPI service that serves clean, validated course and weather data from the
BitByte Practice API. It is the project of *Git, APIs and Backend for AI: Beginner*.
## Endpoints
| Request | Returns |
|---|---|
| `GET /health` | `{"status": "ok"}` |
| `GET /courses?level=&min_rating=&sort=` | `{"count": n, "items": [...]}`; `sort` is `id`, `rating` or `hours` |
| `GET /courses/{course_id}` | one course |
| `GET /summary` | per level: number of courses, total hours, average rating |
| `GET /weather/{city}` | temperature, humidity, condition and a one-line advice |
Errors are JSON, `{"detail": "..."}`:
| Status | Meaning |
|---|---|
| 404 | no such course or city |
| 422 | a parameter is invalid (the body says which) |
| 502 | the Practice API failed, is unreachable or sent unexpected data |
| 504 | the Practice API did not answer in time |
## Run it
Needs Python 3.12+ and [uv](https://docs.astral.sh/uv/). Start the BitByte Practice API first.
```bash
uv sync # create .venv and install the locked versions
uv run fastapi dev # development server with auto-reload; open /docs
```
Example: `GET /weather/sylhet` returns
```json
{"city": "Sylhet", "temperature_c": 27.4, "humidity_pct": 85, "condition": "Rain", "advice": "Carry an umbrella."}
```
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `PRACTICE_API_URL` | the Practice API on this computer (see `app/upstream.py`) | base URL of the upstream API |
| `UPSTREAM_TIMEOUT` | `5` | seconds to wait for the upstream before answering 504 |
## Tests
```bash
uv run pytest -q
```
The tests replace the Practice API with fakes, so they need no network and no running server.
## Project layout
```
app/main.py routes, parameters and status codes
app/upstream.py every call to the Practice API
app/schemas.py Pydantic models
tests/ pytest tests (endpoints and client)
```
EOF
head -n 4 README.md# BitByte Data Explorer API
A small FastAPI service that serves clean, validated course and weather data from the
BitByte Practice API. It is the project of *Git, APIs and Backend for AI: Beginner*.Step 7: A .gitignore that covers the tools
Running the tests created a .pytest_cache/ folder. pytest happens to put its own .gitignore inside it, but coverage reports, linters and notebooks do not, and a reader of your .gitignore should see every rule in one place. The final version:
# file: .gitignore
# Python
__pycache__/
*.pyc
# Virtual environment (rebuilt from uv.lock with `uv sync`)
.venv/
# Secrets and local settings: commit a .env.example with fake values instead
.env
.env.*
!.env.example
# Test and tool caches
.pytest_cache/
.coverage
htmlcov/
.ruff_cache/
# Logs from local runs
*.logCheck that the rules do what you think, including for files that do not exist yet:
git check-ignore -v .env.local .pytest_cache/README.md server.log app/__pycache__/main.cpython-314.pyc
git check-ignore -v .env.example || echo ".env.example is not ignored"
git status --short.gitignore:11:.env.* .env.local
.gitignore:15:.pytest_cache/ .pytest_cache/README.md
.gitignore:21:*.log server.log
.gitignore:3:__pycache__/ app/__pycache__/main.cpython-314.pyc
.gitignore:12:!.env.example .env.example
M .gitignore
?? README.md-v shows which line of which file matched. !.env.example re-includes the example file after .env.* excluded it; the real secrets stay out. git status lists only the two files you meant to change. Commit them separately, one purpose per commit:
git add README.md
git commit -q -m "Add README with endpoints, setup, configuration and tests"
git add .gitignore
git commit -q -m "Ignore test caches and local env files"
git log --oneline main..feature/data-explorer26ccb95 Ignore test caches and local env files
a7d6e20 Add README with endpoints, setup, configuration and tests
b5fc148 Add endpoint and client tests with a faked upstream
3a8f75a Add courses, summary and weather endpoints
089f152 Add Practice API client with timeouts, pagination and validation
6bba991 Add response and upstream schemasmain..feature/data-explorer means "commits on the feature branch that are not on main": exactly the list a pull request will show. Read the messages as a reviewer would: each one says what changed, in the imperative, and none says "fix stuff" or "wip".
Step 8: Push the branch and open a pull request
As in GitHub: Remotes, push, pull and clone, a local bare repository stands in for GitHub. From Git's point of view it is exactly what GitHub is: a repository without a working folder that you push to and fetch from.
git init -q --bare ~/remotes/data-explorer.git
git remote add origin ~/remotes/data-explorer.git
git push -q origin main
git push -u origin feature/data-explorerTo /home/nadia/remotes/data-explorer.git
* [new branch] feature/data-explorer -> feature/data-explorer
branch 'feature/data-explorer' set up to track 'origin/feature/data-explorer'.On GitHub you would now click Compare & pull request, choose main as the base, and write the description. With the gh CLI the same thing is one command (this needs a real GitHub repository, so it is not run here):
gh pr create --base main --head feature/data-explorer \
--title "Data Explorer API: courses, summary and weather" \
--body-file pr-description.md
gh pr view --webA good description tells the reviewer what to look at and how to check it. This is the one for this branch:
## What
A FastAPI service over the BitByte Practice API: /courses (filter, sort),
/courses/{id}, /summary and /weather/{city}, with /health.
## Why
The study assistant needs short, validated answers from one place
instead of calling the Practice API directly.
## How
- app/upstream.py owns every upstream call: shared session, timeout on
every request, pagination with a page limit, Pydantic validation.
- Upstream trouble becomes UpstreamError -> 502 (504 for timeouts)
through one exception handler.
## How to test
uv sync && uv run pytest -q (15 tests, no network needed)
Manual: start the Practice API, uv run fastapi dev, open /docs.
## Not in this PR
Caching and retries for upstream calls.Step 9: The review
Reviewing is a skill of its own, and a checklist keeps it from depending on the reviewer's mood. This is a good one for a small API change; it is also the list to run through on your own code before you ask anyone else:
| Area | Questions |
|---|---|
| Behaviour | Does it do what the brief says? Are the status codes right for each case (200, 404, 422, 502, 504)? |
| Failure paths | Timeout on every outbound call? What happens when the upstream is down, slow, or sends bad data? |
| Security | No secrets in code or history? Inputs limited? Error messages free of internal details and upstream data? |
| Tests | Do they cover the failure paths, not only the happy path? Do they run without network? Do they fail when the code is wrong? |
| Design and readability | Does each module have one job? Are names clear? Is there logic in the wrong place? |
| Docs | Is the README updated? Are new settings documented? |
| Git hygiene | Small commits with clear messages? No generated files, logs or .venv in the diff? |
The reviewer starts from the size of the change, then reads the diff file by file:
git diff --stat main...feature/data-explorer .gitignore | 10 +++++++-
README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++
app/main.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
app/schemas.py | 43 +++++++++++++++++++++++++++++++
app/upstream.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 10 ++++++++
tests/conftest.py | 53 +++++++++++++++++++++++++++++++++++++++
tests/test_api.py | 62 +++++++++++++++++++++++++++++++++++++++++++++
tests/test_upstream.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++
uv.lock | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 559 insertions(+), 1 deletion(-)Tanvir, a teammate, reviews the PR and leaves two comments:
1.
app/main.py: "advice_forand the summary maths are business rules, but they live in the routes file. Can they move to their own module? Then they can be unit-tested without HTTP."2.
/summary: "The levels come back in whatever order the upstream happens to list courses. The website wants a fixed order: beginner, intermediate, advanced."
The first is a refactor: a change of structure that must not change behaviour. The second is a behaviour change (a bug fix). Keep them in separate commits, refactor first: if something breaks, you know which of the two did it.
Step 10: Refactor with the tests as a safety net
Move the two rules, unchanged, into app/services.py:
# file: app/services.py
"""Business rules of the Data Explorer, kept free of HTTP so they are easy to test."""
from app.schemas import Course, LevelSummary
def advice_for(temp_c: float, condition: str) -> str:
if condition == "Rain":
return "Carry an umbrella."
if temp_c >= 30:
return "Hot day: drink water and avoid the midday sun."
return "Good weather to go out."
def summarise(courses: list[Course]) -> list[LevelSummary]:
by_level: dict[str, list[Course]] = {}
for course in courses:
by_level.setdefault(course.level, []).append(course)
return [
LevelSummary(
level=level,
courses=len(group),
total_hours=sum(c.hours for c in group),
average_rating=round(sum(c.rating for c in group) / len(group), 2),
)
for level, group in by_level.items()
]And make the routes use them. Routes now only translate between HTTP and the rest of the code:
# file: app/main.py
"""BitByte Data Explorer API: clean, validated endpoints over the BitByte Practice API."""
from typing import Annotated, Literal
from fastapi import FastAPI, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse
from app import services, upstream
from app.schemas import Course, CourseList, Level, LevelSummary, Weather
app = FastAPI(title="BitByte Data Explorer API", version="0.1.0")
@app.exception_handler(upstream.UpstreamError)
def upstream_failed(request: Request, exc: upstream.UpstreamError) -> JSONResponse:
return JSONResponse({"detail": exc.message}, status_code=exc.status_code)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/courses")
def list_courses(
level: Level | None = None,
min_rating: Annotated[float, Query(ge=0, le=5)] = 0,
sort: Literal["id", "rating", "hours"] = "id",
) -> CourseList:
courses = [c for c in upstream.fetch_courses(level) if c.rating >= min_rating]
courses.sort(key=lambda c: getattr(c, sort), reverse=sort != "id")
return CourseList(count=len(courses), items=courses)
@app.get("/courses/{course_id}")
def get_course(course_id: Annotated[int, Path(ge=1)]) -> Course:
course = upstream.fetch_course(course_id)
if course is None:
raise HTTPException(404, f"Course {course_id} not found")
return course
@app.get("/summary")
def summary() -> list[LevelSummary]:
return services.summarise(upstream.fetch_courses())
@app.get("/weather/{city}")
def weather(city: Annotated[str, Path(min_length=2, max_length=40)]) -> Weather:
data = upstream.fetch_weather(city)
if data is None:
raise HTTPException(404, f"No weather data for {city}")
return Weather(
city=data.city,
temperature_c=data.temp_c,
humidity_pct=data.humidity,
condition=data.condition,
advice=services.advice_for(data.temp_c, data.condition),
)The endpoint tests were written against HTTP, not against the inside of main.py, so they did not have to change. Run them: if they pass, the behaviour is the same.
uv run pytest -q............... [100%]
15 passed in 0.05sNow that advice_for is a plain function, its edge cases are cheap to test, without a client or a fake. The interesting value is the boundary, exactly 30 degrees:
# file: tests/test_services.py
"""Unit tests for the business rules: plain functions, no HTTP, no fakes."""
import pytest
from app import services
@pytest.mark.parametrize(
("temp_c", "condition", "advice"),
[
(25.0, "Rain", "Carry an umbrella."),
(35.0, "Rain", "Carry an umbrella."),
(30.0, "Sunny", "Hot day: drink water and avoid the midday sun."),
(29.9, "Sunny", "Good weather to go out."),
],
)
def test_advice_for(temp_c, condition, advice):
assert services.advice_for(temp_c, condition) == advice
def test_summarise_counts_every_course(fake_courses):
rows = services.summarise(fake_courses)
assert sum(row.courses for row in rows) == len(fake_courses)uv run pytest -q
git add app/services.py app/main.py tests/test_services.py
git commit -q -m "Move business rules into app/services.py".................... [100%]
20 passed in 0.04sStep 11: Fix the bug, test first
For the second comment, write the test that describes the wanted behaviour before the fix. The fake courses list beginner, advanced, beginner, intermediate, so the current code returns the levels in the wrong order, and the test should fail:
cat >> tests/test_services.py <<'EOF'
def test_summarise_uses_a_fixed_level_order(fake_courses):
levels = [row.level for row in services.summarise(fake_courses)]
assert levels == ["beginner", "intermediate", "advanced"]
EOF
uv run pytest -q --tb=short.............F....... [100%]
============================================= FAILURES =============================================
_____________________________ test_summarise_uses_a_fixed_level_order ______________________________
tests/test_services.py:28: in test_summarise_uses_a_fixed_level_order
assert levels == ["beginner", "intermediate", "advanced"]
E AssertionError: assert ['beginner', ...intermediate'] == ['beginner', ...', 'advanced']
E
E At index 1 diff: 'advanced' != 'intermediate'
E Use -v to get more diff
===================================== short test summary info ======================================
FAILED tests/test_services.py::test_summarise_uses_a_fixed_level_order - AssertionError: assert [...
1 failed, 20 passed in 0.06sRead the failure from the bottom up: the summary line names the test, and the assertion shows both lists, with the first difference at index 1. A failing test you have seen fail is worth more than one you have only seen pass: it proves the test can detect this bug. With the Practice API's real data the order happened to be right, which is exactly why nobody noticed. Now the fix. The level order already exists in one place, the Level type, so take it from there instead of writing the list again:
# file: app/services.py
"""Business rules of the Data Explorer, kept free of HTTP so they are easy to test."""
from typing import get_args
from app.schemas import Course, Level, LevelSummary
LEVELS = get_args(Level) # ("beginner", "intermediate", "advanced")
def advice_for(temp_c: float, condition: str) -> str:
if condition == "Rain":
return "Carry an umbrella."
if temp_c >= 30:
return "Hot day: drink water and avoid the midday sun."
return "Good weather to go out."
def summarise(courses: list[Course]) -> list[LevelSummary]:
by_level: dict[str, list[Course]] = {}
for course in courses:
by_level.setdefault(course.level, []).append(course)
summaries = []
for level in LEVELS: # our fixed order, not the upstream's
group = by_level.get(level)
if group:
summaries.append(LevelSummary(
level=level,
courses=len(group),
total_hours=sum(c.hours for c in group),
average_rating=round(sum(c.rating for c in group) / len(group), 2),
))
return summariesuv run pytest -q
git add app/services.py tests/test_services.py
git commit -q -m "Return summary levels in a fixed order"
git push..................... [100%]
21 passed in 0.04s
To /home/nadia/remotes/data-explorer.git
26ccb95..96a7ec5 feature/data-explorer -> feature/data-explorerPlain git push works because -u set up tracking earlier. On GitHub the pull request updates itself with the two new commits, and you would reply to each comment with the commit that addresses it. Tanvir approves.
Step 12: Merge, clean up, and check from a fresh clone
On GitHub you press Merge pull request, and GitHub makes a merge commit on main. A bare repository has no button, so you make the same merge commit yourself and push it. The result in the repository is identical:
git switch main
git merge --no-ff feature/data-explorer -m "Merge pull request #1 from nadia/feature/data-explorer"
git push origin mainSwitched to branch 'main'
Merge made by the 'ort' strategy.
.gitignore | 10 +++++++-
README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++
app/main.py | 59 +++++++++++++++++++++++++++++++++++++++++++
app/schemas.py | 43 +++++++++++++++++++++++++++++++
app/services.py | 32 ++++++++++++++++++++++++
app/upstream.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 10 ++++++++
tests/conftest.py | 53 +++++++++++++++++++++++++++++++++++++++
tests/test_api.py | 62 +++++++++++++++++++++++++++++++++++++++++++++
tests/test_services.py | 28 +++++++++++++++++++++
tests/test_upstream.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++
uv.lock | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 600 insertions(+), 1 deletion(-)
create mode 100644 README.md
create mode 100644 app/main.py
create mode 100644 app/schemas.py
create mode 100644 app/services.py
create mode 100644 app/upstream.py
create mode 100644 tests/conftest.py
create mode 100644 tests/test_api.py
create mode 100644 tests/test_services.py
create mode 100644 tests/test_upstream.py
To /home/nadia/remotes/data-explorer.git
04b26f9..b94b4cd main -> mainThen delete the branch, locally and on the remote. It is merged, so nothing is lost: its commits are part of main now.
git branch -d feature/data-explorer
git push origin --delete feature/data-explorer
git log --oneline --graph --decorateDeleted branch feature/data-explorer (was 96a7ec5).
To /home/nadia/remotes/data-explorer.git
- [deleted] feature/data-explorer
* b94b4cd (HEAD -> main, origin/main) Merge pull request #1 from nadia/feature/data-explorer
|\
| * 96a7ec5 Return summary levels in a fixed order
| * 40763f0 Move business rules into app/services.py
| * 26ccb95 Ignore test caches and local env files
| * a7d6e20 Add README with endpoints, setup, configuration and tests
| * b5fc148 Add endpoint and client tests with a faked upstream
| * 3a8f75a Add courses, summary and weather endpoints
| * 089f152 Add Practice API client with timeouts, pagination and validation
| * 6bba991 Add response and upstream schemas
|/
* 04b26f9 Set up the Data Explorer project with uvThe graph shows the story a teammate will read later: the setup on main, the feature developed on a branch with each step and each review fix as its own commit, and one merge commit that says "this came in through PR #1". --no-ff forces that merge commit even though a fast-forward was possible. GitHub's other two buttons do something different: Squash and merge turns the whole branch into one commit on main, and Rebase and merge copies the commits onto main without a merge commit. Many teams squash small PRs; the advanced tutorial compares the three.
The last check is the one that catches "works on my machine": clone the remote into a new folder, install from the lock file, and run the tests. A file you forgot to commit, or one your .gitignore hides by mistake, shows up here and nowhere else:
cd ~
git clone -q ~/remotes/data-explorer.git data-explorer-check
cd data-explorer-check
uv sync -q
ls
uv run pytest -qREADME.md
app
pyproject.toml
tests
uv.lock
..................... [100%]
21 passed in 0.04sAnd a smoke test against the real Practice API, from the fresh clone, to see the review fix live:
export PRACTICE_API_URL=http://localhost:8800
uv run fastapi run --port 8000 > server.log 2>&1 &
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health > /dev/null
curl -s http://localhost:8000/summary | python -m json.tool[
{
"level": "beginner",
"courses": 5,
"total_hours": 42,
"average_rating": 4.64
},
{
"level": "intermediate",
"courses": 2,
"total_hours": 11,
"average_rating": 4.55
},
{
"level": "advanced",
"courses": 3,
"total_hours": 30,
"average_rating": 4.67
}
]The project is done: tested, documented, reviewed, merged and verified from a clean checkout. On GitHub it would now be a public repository you can link from your CV.
Where this shows up
- Testing LLM features: the
fake_upstreamfixture is the same idea as a fake LLM provider in an AI backend: fixed, instant answers, plus a list of the calls made, so you can assert "the prompt contained the user's question" or "no model call was made for invalid input". - CI: the fresh-clone check is what a CI server does on every push: clone,
uv sync,pytest. The advanced tutorial's GitHub Actions page automates exactly these commands, and a branch rule can then block merging until they pass. - Code review culture: small PRs, clear descriptions and a shared checklist are how teams keep quality high without slowing down, and reviewing others' PRs is one of the fastest ways to learn a codebase.
- Portfolio: a repository with a README, tests and a clean history is the most convincing thing a junior AI engineer can show an employer.
Common mistakes
- Tests that call the real upstream. They are slow, flaky, cannot reach the failure paths, and with paid APIs they cost money on every run. Fix: fake the upstream; keep one separate smoke test for the real connection.
- Patching the wrong name.
monkeypatch.setattr("app.upstream.fetch_courses", ...)does nothing ifmain.pydidfrom app.upstream import fetch_courses. Fix: call through the module (upstream.fetch_courses) and patch it there, or patch the name in the module that uses it. - Expected values copied from the output. Then the test passes whatever the code does. Fix: compute expected values by hand from small, made-up data.
- Mixing a refactor and a fix in one commit. If tests break, you cannot tell which change did it, and the reviewer cannot either. Fix: refactor first with tests green, then the behaviour change with its own test.
- Never trying a fresh clone. A missing file or an over-eager
.gitignorerule only shows up on someone else's machine. Fix: clone, sync and test before you call it done (and let CI do it on every push).
Try it yourself
- Easy: In
~/projects/data-explorer(now onmain), add a test that/weather/x(one character) is rejected with 422 and never reaches the upstream. Run the tests. - Medium: Add a client test proving that
fetch_coursesraisesUpstreamErrorwhen the upstream keeps sending anext_pageforever. Usemonkeypatchto setupstream.MAX_PAGESto 3 so the test stays fast. - Hard: Run the whole loop again for a small feature: a branch
feature/levels, aGET /levelsendpoint returning the sorted list of levels that have courses, a test for it, a commit, a push, a--no-ffmerge intomain, a push ofmainand the deletion of the branch on both sides.
Answers
cd ~/projects/data-explorer
cat >> tests/test_api.py <<'EOF'
def test_one_letter_city_is_rejected(client, fake_upstream):
assert client.get("/weather/x").status_code == 422
assert fake_upstream == []
EOF
cat >> tests/test_upstream.py <<'EOF'
def test_endless_pages_are_stopped(monkeypatch):
monkeypatch.setattr(upstream, "MAX_PAGES", 3)
def fake_get(url, params=None, timeout=None):
return FakeResponse(200, {"items": [course(params["page"])], "next_page": params["page"] + 1})
monkeypatch.setattr(upstream.session, "get", fake_get)
with pytest.raises(upstream.UpstreamError, match="too many pages"):
upstream.fetch_courses()
EOF
uv run pytest -q....................... [100%]
23 passed in 0.04s# 3. The whole loop for GET /levels
git add tests/
git commit -q -m "Test the city length limit and the page limit"
git switch -q -c feature/levels
cat >> app/main.py <<'EOF'
@app.get("/levels")
def levels() -> list[str]:
return sorted({course.level for course in upstream.fetch_courses()})
EOF
cat >> tests/test_api.py <<'EOF'
def test_levels(client, fake_upstream):
assert client.get("/levels").json() == ["advanced", "beginner", "intermediate"]
EOF
uv run pytest -q
git add app/main.py tests/test_api.py
git commit -q -m "Add GET /levels"
git push -q -u origin feature/levels
git switch -q main
git merge -q --no-ff feature/levels -m "Merge pull request #2 from nadia/feature/levels"
git push -q origin main
git branch -q -d feature/levels
git push -q origin --delete feature/levels
git log --oneline --graph -n 6........................ [100%]
24 passed in 0.05s
* 0bc9e2a Merge pull request #2 from nadia/feature/levels
|\
| * 3eac711 Add GET /levels
|/
* 15de305 Test the city length limit and the page limit
* b94b4cd Merge pull request #1 from nadia/feature/data-explorer
|\
| * 96a7ec5 Return summary levels in a fixed order
| * 40763f0 Move business rules into app/services.pyThe first two commands commit the answers to exercises 1 and 2 directly on main to keep this short; in a team they would go through a PR as well. On GitHub, "Merge pull request #2" would come from pressing the button; the rest of the loop is the same.
Summary
- Test a service that depends on another API at two levels: fake the client functions to test your routes, fake the HTTP call to test your client. No test touches the network.
monkeypatchreplaces a name for one test and restores it afterwards; patch it where it is looked up. Fakes that record calls let you assert what was (and was not) sent upstream.- A README covers what, run, use, test and configure, with examples a reader can copy;
.gitignorelists every tool's junk and keeps.env.example. - A pull request is a branch plus a description plus a review. Review against a checklist; answer feedback with small commits; refactor and fix separately; write the failing test before the fix.
- Merge, delete the branch, then prove it from a fresh clone:
git clone,uv sync,uv run pytest.
Next: Summary, Cheat Sheet and What's Next puts the commands of the whole tutorial on one page, from the terminal and Git to HTTP, FastAPI and pytest, and shows what the advanced tutorial adds.