
This is Part 4 of a series. Catch up on Part 1: A Practical Crash Course, Part 2: FastAPI with a Real Database, and Part 3: JWT Authentication.
In Parts 2 and 3, we built an API with a database and JWT login. It works when you click around in /docs, but that doesn't scale. This time, we write automated tests, including tests for the protected routes. A reader asked about exactly this after Part 2, so thank you for the nudge.
1. Install what you need
pip install pytest httpx
pytest is the test runner (think PHPUnit), and FastAPI's TestClient uses httpx under the hood to send requests to your app without starting a server.
2. PHPUnit to pytest: the quick mapping
| PHP / Laravel | Python / pytest |
|---|---|
setUp() |
fixtures |
$this->assertEquals(a, b) |
assert a == b |
RefreshDatabase |
a fixture that creates and drops tables |
actingAs($user) |
a fixture that returns an auth header |
tests/FooTest.php |
tests/test_foo.py |
Tests are plain functions named test_..., and assertions are plain assert statements. No test class is required.
3. A test database via dependency override
In Part 2, every route got its database session from Depends(get_db). That pays off now: in tests, we swap get_db for a version that points at an in-memory SQLite database. No route code changes.
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from main import app, get_db
from database import Base
engine = create_engine(
"sqlite://", # in-memory database
connect_args={"check_same_thread": False},
poolclass=StaticPool, # keeps one shared connection
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
Everything before yield is setup, and everything after is teardown. Each test starts with empty tables and leaves nothing behind.
4. Testing public routes
# tests/test_items.py
def test_list_items_empty(client):
response = client.get("/items")
assert response.status_code == 200
assert response.json() == []
Notice that client is just a function argument. pytest sees the name, finds the fixture, and passes it in.
5. Testing protected routes
Add a second fixture that registers a user, logs in, and returns the header:
# tests/conftest.py (continued)
@pytest.fixture()
def auth_headers(client):
client.post("/register", params={"username": "himani", "password": "secret123"})
response = client.post(
"/login", data={"username": "himani", "password": "secret123"}
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
Now test both sides of the protection:
# tests/test_items.py (continued)
def test_create_item_requires_login(client):
response = client.post("/items", json={"name": "Pen", "price": 1.5})
assert response.status_code == 401
def test_create_item_with_token(client, auth_headers):
response = client.post(
"/items", json={"name": "Pen", "price": 1.5}, headers=auth_headers
)
assert response.status_code == 200
assert response.json()["name"] == "Pen"
def test_login_wrong_password(client):
client.post("/register", params={"username": "himani", "password": "secret123"})
response = client.post(
"/login", data={"username": "himani", "password": "wrong"}
)
assert response.status_code == 401
Always test the failure case too. A protected route that works with a token is only half the story. You also need proof it rejects requests without one.
6. Run the tests
pytest -v
7. Gotchas for PHP developers
In-memory SQLite needs
StaticPool. Without it, each new connection gets its own empty database, and your tables seem to vanish between requests.Always clear
dependency_overrides. If you forget, the override leaks into other tests and they silently talk to the wrong database.Fixtures are not
setUp(). They only run for tests that ask for them by name, which keeps tests explicit and fast.Don't test against your real database. One bad test run can wipe real data. The override pattern exists to prevent that.
Test the failure paths. Wrong password, missing token, and expired token deserve as many tests as the happy path.
What's next
The API now has a database, authentication, and a test suite. Natural next steps are refresh tokens, role-based permissions, and running these tests automatically in CI.
What do you test first when you build a new API? Let me know in the comments.
About the author
- Building production-ready AI applications with Python, FastAPI, LangChain & Agentic AI. Sharing real-world engineering lessons along the way.
Comments
Join the discussionNo comments yet. Be the first to comment.




