Skip to content

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"]
uv sync --group test

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

make test

What happens behind the scenes:

  1. Postgres container starts (random port)
  2. Redis container starts (random port)
  3. Safety gates verify all URLs (localhost, test_ prefix)
  4. create_all() builds the tables
  5. Factory modules imported, create_user and create_todo registered
  6. Each test gets a SAVEPOINT session, runs, ROLLBACK
  7. 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.