Walkthrough: Todo app¶
A complete example showing every file and command. We're adding tests to a FastAPI Todo app with users, auth, and todos.
The app¶
todo-app/
src/
app/
main.py # create_app()
deps.py # get_session dependency
models.py # Base
auth/
models.py # User
routes.py # POST /auth/register, POST /auth/login
service.py # create_user, authenticate, hash_password
todos/
models.py # Todo
routes.py # CRUD endpoints
service.py # create_todo, list_todos, complete_todo
docker-compose.yml
pyproject.toml
Step 1: Install¶
pyproject.toml
[dependency-groups]
test = [
"bluefox-test>=0.1.0,<1.0",
"pytest-xdist>=3.5",
]
[tool.pytest.ini_options]
testpaths = ["src", "e2e"]
pythonpath = ["src"]
asyncio_mode = "auto"
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = ["--strict-markers", "--tb=short", "-q"]
Step 2: Root conftest¶
conftest.py
from bluefox_test import bluefox_test_setup
from app.models import Base
globals().update(bluefox_test_setup(base=Base))
You now have db, engine, app, client, and all create_* fixtures.
Step 3: Create test directories¶
mkdir -p src/auth/tests src/todos/tests
touch src/auth/tests/__init__.py src/todos/tests/__init__.py
Step 4: Write auth factories¶
src/auth/tests/factories.py
from bluefox_test import BaseFactory, Faker, register
from auth.models import User
class UserFactory(BaseFactory):
class Meta:
model = User
email = Faker("email")
name = Faker("name")
password_hash = "hashed_placeholder"
is_active = True
create_user = register(UserFactory)
Step 5: Write todo factories¶
src/todos/tests/factories.py
from bluefox_test import BaseFactory, Faker, register
from todos.models import Todo
class TodoFactory(BaseFactory):
class Meta:
model = Todo
title = Faker("sentence", nb_words=4)
description = ""
is_completed = False
# owner is NOT declared — always pass explicitly
create_todo = register(TodoFactory)
Tip
Don't declare owner on the factory. Pass it explicitly in every test. This is clearer and avoids hidden database calls.
Step 6: Write auth tests¶
src/auth/tests/test_routes.py
async def test_register__creates_new_user(client):
response = await client.post("/auth/register", json={
"email": "new@example.com",
"name": "New User",
"password": "securepassword123",
})
assert response.status_code == 201
assert response.json()["email"] == "new@example.com"
async def test_register__rejects_duplicate_email(client, create_user):
await create_user(email="taken@example.com")
response = await client.post("/auth/register", json={
"email": "taken@example.com",
"name": "Duplicate",
"password": "password123",
})
assert response.status_code == 409
async def test_login__returns_jwt(client, create_user):
from auth.service import hash_password
await create_user(
email="hugo@example.com",
password_hash=hash_password("mypassword"),
)
response = await client.post("/auth/login", json={
"email": "hugo@example.com",
"password": "mypassword",
})
assert response.status_code == 200
assert "access_token" in response.json()
async def test_login__rejects_wrong_password(client, create_user):
from auth.service import hash_password
await create_user(
email="hugo@example.com",
password_hash=hash_password("correctpassword"),
)
response = await client.post("/auth/login", json={
"email": "hugo@example.com",
"password": "wrongpassword",
})
assert response.status_code == 401
Step 7: Write todo tests¶
src/todos/tests/test_routes.py
async def test_list_todos__returns_only_own_todos(client, create_user, create_todo):
owner = await create_user(email="owner@example.com")
other = await create_user(email="other@example.com")
await create_todo(title="My task", owner=owner)
await create_todo(title="Their task", owner=other)
response = await client.get("/todos", headers=auth_headers(owner))
assert response.status_code == 200
todos = response.json()
assert len(todos) == 1
assert todos[0]["title"] == "My task"
async def test_create_todo__creates_with_defaults(client, create_user):
owner = await create_user()
response = await client.post("/todos", json={
"title": "Buy milk",
}, headers=auth_headers(owner))
assert response.status_code == 201
assert response.json()["is_completed"] is False
async def test_complete_todo(client, create_user, create_todo):
owner = await create_user()
todo = await create_todo(title="Buy milk", owner=owner)
response = await client.patch(
f"/todos/{todo.id}/complete",
headers=auth_headers(owner),
)
assert response.status_code == 200
assert response.json()["is_completed"] is True
def auth_headers(user) -> dict:
from auth.service import create_access_token
token = create_access_token(user_id=user.id)
return {"Authorization": f"Bearer {token}"}
Step 8: Service-layer tests¶
src/todos/tests/test_service.py
from todos.service import list_todos_for_user, complete_todo
async def test_list_todos__returns_empty_for_new_user(db, create_user):
user = await create_user()
todos = await list_todos_for_user(db, user_id=user.id)
assert todos == []
async def test_complete_todo__sets_is_completed(db, create_user, create_todo):
user = await create_user()
todo = await create_todo(owner=user, is_completed=False)
updated = await complete_todo(db, todo_id=todo.id)
assert updated.is_completed is True
Step 9: Constraint test¶
The ONE case where you need .commit():
src/auth/tests/test_models.py
import pytest
from sqlalchemy.exc import IntegrityError
async def test_user__enforces_unique_email(db, create_user):
"""Verify the UNIQUE constraint exists in the schema."""
await create_user(email="unique@example.com")
await db.commit()
with pytest.raises(IntegrityError):
await create_user(email="unique@example.com")
await db.commit()
Step 10: Run¶
What happens behind the scenes:
- Postgres container starts (random port)
- Redis container starts (random port)
- Safety gates verify all URLs (localhost,
test_prefix) create_all()builds the tables- Factory modules imported,
create_userandcreate_todoregistered - Each test gets a SAVEPOINT session, runs, ROLLBACK
- Containers stop
You configured none of that.
Line count¶
| File | Lines | Purpose |
|---|---|---|
conftest.py | 3 | Root setup |
auth/tests/factories.py | 15 | User factory |
todos/tests/factories.py | 15 | Todo factory |
| Total infrastructure | ~33 |
Everything else — containers, sessions, safety, factory discovery, SAVEPOINT management — is in the bluefox-test package.