Lesson 5 - Capstone: Test and Deploy It
Welcome to the Finish Line
In Part 1 you built the whole thing: a Task Manager API with pydantic-settings configuration, a SQLModel database holding User and Task, JWT-based registration and login, tidy routers, and a /health endpoint. It runs. But “it runs” and “it ships” are two different claims, and the gap between them is exactly what this final lesson closes. You’ll prove the API works with an automated test suite that actually passes, confirm it’s configured the safe way through the environment, and package it into a deployable artifact with a requirements.txt, a Dockerfile, and the production uvicorn command. By the end you’ll have a complete, tested, shippable FastAPI application — and you’ll have finished the course.
By the end of this part, you will be able to:
- Write a real
pytestsuite covering health, auth, per-user data, and error paths - Run that suite and read its passing output as proof the API behaves
- Confirm your secret and database come from the environment, never from code
- Package the API for deployment with a
requirements.txt,Dockerfile, anduvicorn
Let’s turn the working project into a shippable one.
Stage 1: Write the Test Suite
A suite is just a collection of the focused tests you learned to write in Lesson 1 — one per behavior you care about. For the Task Manager, “behaviors you care about” means the whole contract: the health check answers, registration and login work (and fail correctly), protected routes reject anonymous callers, tasks can be created and listed, one user can never see another’s tasks, and deletion works (and 404s when there’s nothing to delete).
Your Part-1 app lives in main.py and exposes app, so the suite imports it and drives it with TestClient. Put this in test_main.py next to your app:
# test_main.py
import warnings; warnings.filterwarnings("ignore")
import os
if os.path.exists("app.db"): os.remove("app.db") # fresh DB for the test run
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def auth_header(username, password):
client.post("/auth/register", data={"username": username, "password": password})
tok = client.post("/auth/token", data={"username": username, "password": password}).json()["access_token"]
return {"Authorization": f"Bearer {tok}"}
def test_health():
r = client.get("/health")
assert r.status_code == 200 and r.json() == {"status": "ok"}
def test_register_and_login():
assert client.post("/auth/register", data={"username": "ada", "password": "wonderland"}).status_code == 201
assert client.post("/auth/register", data={"username": "ada", "password": "x"}).status_code == 400
assert client.post("/auth/token", data={"username": "ada", "password": "wrong"}).status_code == 401
def test_tasks_require_auth():
assert client.get("/tasks").status_code == 401
def test_create_and_list_task():
h = auth_header("bob", "builder")
created = client.post("/tasks", json={"title": "Write the report"}, headers=h)
assert created.status_code == 201 and created.json()["owner"] == "bob"
assert len(client.get("/tasks", headers=h).json()) == 1
def test_tasks_isolated_per_user():
ha = auth_header("carol", "pw1"); client.post("/tasks", json={"title": "Carol task"}, headers=ha)
hd = auth_header("dave", "pw2")
assert client.get("/tasks", headers=hd).json() == []
def test_delete_task():
h = auth_header("erin", "pw")
tid = client.post("/tasks", json={"title": "temp"}, headers=h).json()["id"]
assert client.delete(f"/tasks/{tid}", headers=h).status_code == 204
assert client.delete(f"/tasks/{tid}", headers=h).status_code == 404A few things are worth pointing out, because they’re the patterns you’ll reuse on every API you test:
- A helper does the boring setup.
auth_headerregisters a user, logs in, and returns theAuthorizationheader — so each task test starts from a logged-in user in one line instead of repeating the register/login dance. - The error paths are tested as carefully as the happy paths.
test_register_and_loginasserts the400for a duplicate username and the401for a wrong password;test_tasks_require_authasserts the401with no token;test_delete_taskasserts the404for a task that’s already gone. These are the behaviors that quietly break during a refactor. test_tasks_isolated_per_userchecks the security boundary. It creates a task as Carol, then logs in as Dave and asserts Dave sees an empty list. That single test is your proof that the per-user filtering in your query actually works — arguably the most important assertion in the file.- We never print a password or a hash. The tests assert on status codes and JSON; the bcrypt hashes stored in the database never leave it.
The first two lines silence library deprecation warnings and delete app.db so the run starts from an empty database — the same data every time, which is what makes a suite repeatable.
Stage 2: Run It and Read the Green
From the folder containing main.py and test_main.py, run pytest. The short form just gives you the verdict:
pytest -q...... [100%]
6 passed in 3.67sSix dots, six passing tests, under four seconds — and most of that is bcrypt deliberately being slow to hash passwords. For the per-test breakdown, add -v:
pytest -vplatform darwin -- Python 3.11.9, pytest-9.1.1, pluggy-1.6.0
collected 6 items
test_main.py::test_health PASSED [ 16%]
test_main.py::test_register_and_login PASSED [ 33%]
test_main.py::test_tasks_require_auth PASSED [ 50%]
test_main.py::test_create_and_list_task PASSED [ 66%]
test_main.py::test_tasks_isolated_per_user PASSED [ 83%]
test_main.py::test_delete_task PASSED [100%]
============================== 6 passed in 3.63s ===============================This is the moment “I think it works” becomes “I know it works.” Every endpoint, every auth check, every error path, and the per-user boundary are all verified by one command you can run in seconds — before every commit, in CI, before every deploy. That green line is the safety net that lets you keep changing this code without fear.
Stage 3: Confirm the Configuration
Before you ship, double-check that nothing secret is hardcoded. Your config.py from Lesson 2 defines the settings as a typed class that reads from the environment, with safe, placeholder defaults:
# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
secret_key: str = "CHANGE-ME-IN-PRODUCTION-USE-A-LONG-RANDOM-SECRET"
access_token_minutes: int = 30
database_url: str = "sqlite:///app.db"
settings = Settings()The whole app reads settings.secret_key and settings.database_url from this one object — never a literal in the route code. In development, the defaults are fine and the placeholder secret is long enough that the JWT library signs tokens without complaint. In production, you override both from the environment, so the same image runs anywhere with no code change:
# Real values come from the environment, namespaced with the APP_ prefix
export APP_SECRET_KEY="<a-long-random-secret-generated-with-openssl-rand>"
export APP_DATABASE_URL="postgresql://user:password@db-host:5432/tasks"And the one rule that protects all of this — keep your real .env out of version control. A .gitignore entry is the whole defense:
# .gitignore
.env
*.db
__pycache__/With .env ignored, a real secret can never be committed by accident. The code in your repository contains zero secrets and zero machine-specific paths, which is exactly what makes it safe to share and deploy.
Stage 4: Package It for Deployment
Three artifacts turn the project into something a server can run. First, pin your dependencies in requirements.txt so the deploy installs the exact versions you tested against:
# requirements.txt
fastapi[standard]==0.138.1
sqlmodel==0.0.39
pydantic-settings==2.14.2
pyjwt==2.13.0
pwdlib[bcrypt]==0.3.0These are every library the capstone uses: FastAPI with its standard extras (which bundle uvicorn and httpx), sqlmodel for the database, pydantic-settings for configuration, pyjwt for tokens, and pwdlib[bcrypt] for password hashing. The == pins mean a deploy months from now installs precisely what passed your tests today.
Second, the Dockerfile — the recipe that packages your app and those dependencies into one image that runs identically everywhere:
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install dependencies first so this layer is cached when only your code changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY . .
# Serve the app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]It reads top to bottom: start from a small official Python base, copy and install the requirements before the code so Docker can reuse the cached install layer whenever only your app changes, copy the application in, then declare the production uvicorn command that runs when the container starts. Build and run it with two commands:
docker build -t task-manager .
docker run -p 8000:8000 \
-e APP_SECRET_KEY="<a-long-random-secret>" \
-e APP_DATABASE_URL="postgresql://user:password@db-host:5432/tasks" \
task-managerNotice the secrets are passed in at run time with -e, never baked into the image. The image is the same everywhere; only the environment changes.
Third — and this is what the Dockerfile’s CMD already invokes — the production uvicorn command. Outside Docker (or to confirm it locally), the production-shaped run looks like this:
# Production: all interfaces, fixed port, 4 worker processes
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4--host 0.0.0.0 accepts connections from outside the container, --port 8000 pins the port, and --workers 4 runs four copies of your app so it can handle four requests at once. Drop the --reload you used while developing; it’s a coding convenience, not a way to serve real traffic.
You built a production shell, not just an app
Step back and look at what you have: typed configuration from the environment, a real database with per-user data, token authentication, a health check, a passing test suite, and a one-command container build. That’s not just a Task Manager — it’s a production shell. The endpoints inside it happen to manage tasks, but the same shell can serve anything. Swap the task logic for a call to a language model and you have an AI service with auth, config, tests, and deployment already solved. That’s exactly the move the next course makes.
Extend the Project
You’ve finished the capstone. Here are three ways to push it further — each one is a real-world improvement teams make to APIs like this.
Exercise 1: Run the tests automatically on every push
Right now you run pytest by hand. Set it up so it runs automatically whenever you push code, blocking a merge if anything fails.
Hint
This is continuous integration (CI). On GitHub, add a workflow file at .github/workflows/tests.yml that checks out the code, runs pip install -r requirements.txt, then runs pytest. A job that runs pytest on every push (and pull request) turns your suite into a gate: code that breaks a test can’t be merged. The same suite you wrote today is what makes the gate meaningful.
Exercise 2: Test that an expired token is rejected
Your get_current_user dependency should reject a token whose expiry has passed. Write a test that proves it.
Hint
Forge a token that’s already expired and confirm the API refuses it: build one with jwt.encode({"sub": "ada", "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, settings.secret_key, algorithm="HS256"), send it as the Authorization header, and assert the response is 401. This proves your auth honors the exp claim — a behavior that’s easy to break and critical to get right.
Exercise 3: Give the tests their own database
The suite deletes app.db before running, which works but reaches into your real database file. Cleaner: point the tests at a separate, throwaway database.
Hint
Use a pytest fixture plus FastAPI’s dependency override. Create a test engine (an in-memory SQLite, sqlite://, is ideal), build a get_session that uses it, and register it with app.dependency_overrides[get_session] = override_get_session. Now the tests run against a fresh, isolated database and never touch the file your dev server uses — the standard way to test data-backed FastAPI apps.
Summary
You finished the capstone by making it shippable. You wrote a pytest suite that covers the full contract of the Task Manager — health, register and login (including the duplicate-username 400 and wrong-password 401), the 401 on protected routes, create and list, per-user isolation, and delete (including the 404) — and you ran it to a clean 6 passed. You confirmed the app’s configuration comes from the environment through pydantic-settings, with the secret and database URL overridable per environment and the real .env kept out of version control. And you packaged the project for deployment with a pinned requirements.txt, a Dockerfile built on python:3.12-slim, and the production uvicorn command with workers — passing secrets in as environment variables, never baking them into the image.
Key Concepts
- Test suite — a collection of focused
pytesttests covering every behavior, including error paths and the per-user security boundary. - Repeatable tests — a fresh database each run so the suite gives the same result every time.
- Environment configuration — secret and database URL read from
APP_-prefixed env vars viapydantic-settings; real.envgitignored. - Deployment artifacts —
requirements.txt(pinned deps),Dockerfile(python:3.12-slim, install, copy,uvicornCMD), and the productionuvicorn --workerscommand. - Secrets at run time — passed as environment variables to the container, never committed or baked into the image.
Why This Matters
This is the full arc of professional API work in one project: build it, prove it with tests, configure it safely, and ship it in a reproducible container. The pattern doesn’t change with the domain — the tested, configured, containerized shell you just finished is the same shape every serious FastAPI service takes. Whether the endpoints manage tasks, process payments, or call a language model, the production discipline is identical, and now it’s yours.
Next Steps
Apply It: Ship an AI Endpoint
In the Generative AI course, the Shipping AI Applications module wraps a Claude call in this exact FastAPI shell — auth, config, tests, and deployment already solved, so you focus on the model.
Back to Course Home
You did it — you completed the FastAPI course. Revisit any module, review the capstone, or pick your next build from the course home.
Continue Building Your Skills
Congratulations — you’ve completed the entire FastAPI course. You started by returning a single JSON response and finished by building, testing, configuring, and packaging a complete, authenticated, database-backed API ready to run in a container. That’s the full journey from “hello world” to a service you could genuinely deploy. The skills are real and they transfer: every production API you build from here follows the shape you just practiced. So take the obvious next step — deploy something real. Put this Task Manager on a server, or wire its production shell around an idea of your own, and watch it serve actual requests. You have everything you need. Go ship it.