Testing with TestClient
TestClient sends fake HTTP requests to your API without starting a real server. Your tests run instantly. This is the same TDG cycle from earlier chapters, applied to web routes.
httpx-based TestClient, same TDG cycle as always but targeting API routes. client.get(), client.post(), response.status_code, response.json(). No running server needed.
In Lessons 3 and 4, you tested routes by clicking through /docs. That works for exploration, but it does not catch regressions. If you change the search logic tomorrow and break the tag filter, you will not know until someone complains.
TestClient solves this. It sends HTTP requests to your FastAPI app without starting uvicorn. Tests run in milliseconds. The same TDG cycle you used for CLI testing in Ch 65 applies here: write tests first, then implement (or verify existing) routes.
Installing and Using TestClient
TestClient comes with FastAPI (via the httpx library). No extra installation needed. Create a new test file at tests/test_api.py:
from fastapi.testclient import TestClient
from smartnotes.api import app
client: TestClient = TestClient(app)
def test_root_returns_status() -> None:
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"status": "SmartNotes API running"}
Run it:
uv run pytest tests/test_api.py -v
Output:
tests/test_api.py::test_root_returns_status PASSED
Three lines per test: send the request, check the status code, check the body. This is the pattern for every API test.
| Step | Code | What it does |
|---|---|---|
| Send | client.get("/") | Sends a GET request to the root route |
| Status | assert response.status_code == 200 | Verifies the HTTP status code |
| Body | assert response.json() == {...} | Verifies the JSON response body |
Notice: no server is running. TestClient handles everything in-process. This is why tests run in milliseconds instead of seconds.
Test Isolation: Start Each Test Clean
Your API stores notes in a module-level list (_notes in api.py). Without cleanup, notes created in one test leak into the next. Add a setup function that clears the list before every test:
from smartnotes.api import _notes
def setup_function() -> None:
"""Clear notes before each test so tests are independent."""
_notes.clear()
Place this near the top of your test file, after the imports. Now every test starts with an empty note list, regardless of execution order.
Testing GET Routes
Add tests for the routes you built in Lessons 3 and 4. Start with listing notes:
def test_list_notes_empty() -> None:
response = client.get("/notes")
assert response.status_code == 200
assert response.json() == []
Then test creating a note and listing it:
def test_create_and_list_note() -> None:
# Create a note
create_response = client.post(
"/notes",
json={"title": "Test Note", "body": "This is a test"},
)
assert create_response.status_code == 201
# List notes and verify the new note appears
list_response = client.get("/notes")
notes: list[dict[str, object]] = list_response.json()
assert len(notes) >= 1
assert notes[-1]["title"] == "Test Note"
Test fetching a single note:
def test_get_note_by_id() -> None:
# Create a note first
create_response = client.post(
"/notes",
json={"title": "Fetch Me", "body": "Find this note by ID"},
)
note_id: int = create_response.json()["id"]
# Fetch it by ID
response = client.get(f"/notes/{note_id}")
assert response.status_code == 200
assert response.json()["title"] == "Fetch Me"
Test the 404 case:
def test_get_note_not_found() -> None:
response = client.get("/notes/99999")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
Test search:
def test_search_notes() -> None:
# Create notes with different content
client.post("/notes", json={"title": "Python Tips", "body": "Learn FastAPI"})
client.post("/notes", json={"title": "Cooking", "body": "Make pasta"})
# Search for Python-related notes
response = client.get("/notes/search?q=python")
assert response.status_code == 200
results: list[dict[str, object]] = response.json()
assert any(note["title"] == "Python Tips" for note in results)
assert not any(note["title"] == "Cooking" for note in results)
Run the full test suite:
uv run pytest tests/test_api.py -v
Output:
tests/test_api.py::test_root_returns_status PASSED
tests/test_api.py::test_list_notes_empty PASSED
tests/test_api.py::test_create_and_list_note PASSED
tests/test_api.py::test_get_note_by_id PASSED
tests/test_api.py::test_get_note_not_found PASSED
tests/test_api.py::test_search_notes PASSED
Testing POST Routes
POST tests send JSON bodies and verify the response:
def test_create_note_returns_201() -> None:
response = client.post(
"/notes",
json={"title": "New Note", "body": "Created via test"},
)
assert response.status_code == 201
data: dict[str, object] = response.json()
assert data["title"] == "New Note"
assert data["word_count"] == 3
assert data["tags"] == []
def test_create_note_with_tags() -> None:
response = client.post(
"/notes",
json={
"title": "Tagged Note",
"body": "This has tags",
"tags": ["python", "fastapi"],
},
)
assert response.status_code == 201
assert response.json()["tags"] == ["python", "fastapi"]
def test_create_note_missing_body_returns_422() -> None:
response = client.post("/notes", json={"title": "Incomplete"})
assert response.status_code == 422
The last test verifies validation. Sending a note without the required body field returns 422. You did not write this validation logic; Pydantic does it from the NoteCreate model.
TDG for APIs
The TDG cycle for API routes follows the same pattern as CLI development (Ch 65):
| TDG Step | CLI (Ch 65) | API (This chapter) |
|---|---|---|
| Stub | Function signature + docstring | Route decorator + function stub |
| Test | subprocess.run + check stdout | client.get/post + check JSON |
| Generate | Prompt AI to implement | Prompt AI to implement |
| Verify | ruff check, pyright, pytest | ruff check, pyright, pytest |
Here is the full cycle for a new route. Suppose you want to add GET /notes/export?format=json:
Step 1: Stub
@app.get("/notes/export")
async def export_notes(format: str = "json") -> list[dict[str, object]]:
"""Export all notes in the specified format."""
...
Step 2: Test
def test_export_json() -> None:
# Create a note
client.post("/notes", json={"title": "Export Test", "body": "Body here"})
# Export as JSON
response = client.get("/notes/export?format=json")
assert response.status_code == 200
data: list[dict[str, object]] = response.json()
assert len(data) >= 1
assert "title" in data[0]
Step 3: Generate
Prompt Claude Code:
Implement the export_notes route in smartnotes/api.py
so that all tests in tests/test_api.py pass. The route
should return all notes as a JSON list. Do not modify
the test file.
Step 4: Verify
uv run ruff check smartnotes/api.py
uv run pyright smartnotes/api.py
uv run pytest tests/test_api.py -v
PRIMM-AI+ Practice: Test a New Route
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
You will write a test for DELETE /notes/{note_id}. Before writing it, predict:
- What status code should a successful delete return?
- What should the response body contain?
- What happens when you try to GET the deleted note after deletion?
- What happens when you try to DELETE a note that does not exist?
Run
Press Shift+Tab to exit Plan Mode.
Write the tests:
def test_delete_note() -> None:
# Create a note
create_response = client.post(
"/notes",
json={"title": "Delete Me", "body": "Temporary note"},
)
note_id: int = create_response.json()["id"]
# Delete it
delete_response = client.delete(f"/notes/{note_id}")
assert delete_response.status_code == 200
# Verify it is gone
get_response = client.get(f"/notes/{note_id}")
assert get_response.status_code == 404
def test_delete_note_not_found() -> None:
response = client.delete("/notes/99999")
assert response.status_code == 404
Run the tests. They should FAIL because the DELETE route does not exist yet (or because the in-memory storage persists between tests). This is expected.
Investigate
Run /investigate @tests/test_api.py in Claude Code and ask: "My tests are affecting each other because the in-memory note list persists between tests. How do I isolate each test so it starts with a fresh state?"
The answer involves pytest fixtures or resetting the global state, a pattern you will use in the capstone.
Modify
Add a test for the search route with the tag query parameter: GET /notes/search?q=test&tag=python. The test should create two notes (one with the tag, one without) and verify that only the tagged note appears.
Make [Mastery Gate]
Write the complete DELETE route implementation using the /tdg cycle:
- The test is already written (above)
- Write the route stub in
api.py - Prompt AI to implement
- Run
uv run pytest tests/test_api.py -vand verify all tests pass
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Review My Tests
Here is my API test file:
[paste tests/test_api.py]
Review these tests:
1. Am I testing enough cases? What scenarios am I missing?
2. Are any tests dependent on each other (order-dependent)?
3. How would I fix the shared state problem where notes
from one test leak into another?
What you're learning: Test isolation is critical for API testing. The AI will likely suggest a fixture that clears the note list before each test, or creating a fresh TestClient per test. This is the same principle from Ch 52 (pytest fixtures) applied to a new domain.
Prompt 2: Compare Testing Approaches
In Chapter 65, I tested the CLI using subprocess.run,
which runs the actual installed command. In this chapter,
I test the API using TestClient, which runs in-process
without starting a server.
Compare these two approaches:
1. Speed difference and why
2. What each approach can and cannot catch
3. When would I use both for the same project?
What you're learning: In-process tests (TestClient) are fast but skip the server layer. Subprocess tests are slower but test the complete stack. Real projects use both: fast in-process tests during development, slower integration tests before deployment. Understanding the trade-offs helps you choose the right approach for each situation.
Prompt 3: Generate Edge Case Tests
My SmartNotes API has these routes:
- POST /notes (create, expects title + body)
- GET /notes (list all)
- GET /notes/{note_id} (get one)
- GET /notes/search?q=...&tag=... (search)
Generate 5 edge case tests I have not written yet.
For each one, explain what it tests and why it matters.
Use the TestClient pattern from my existing tests.
What you're learning: Edge cases expose fragile assumptions. The AI will suggest tests for empty strings, very long inputs, special characters, concurrent creation, and boundary conditions. These are the tests that prevent production bugs but are easy to forget during initial development.
James looks at the test file. Eleven tests, all green. "TestClient is like the inspection lane in the warehouse. Every shipment passes through. If something is wrong, the inspection catches it before it reaches the customer."
Emma nods, then adds, "I made a mistake once. I wrote all my API tests using TestClient but never tested with a real server. Everything passed. Then I deployed and the server would not start because of a missing environment variable that TestClient never triggered." She pauses. "TestClient tests your routes. It does not test your deployment. Keep that distinction in mind."
"So I need both kinds of tests?"
"Eventually. For now, TestClient covers your routes. The capstone ties everything together: real storage, real routes, real tests. If all of that passes, you have a working API."