Skip to content

Getting started

How to set up testing in a FastAPI + SQLAlchemy app using bluefox-test.

Prerequisites: Docker running. That's it.


1. Install

pyproject.toml
[dependency-groups]
test = [
    "bluefox-test>=0.1.0,<1.0",
    "pytest-xdist>=3.5",        # optional: parallel workers
    "playwright>=1.44",          # optional: UI e2e tests
]
uv sync --group test

2. Pytest config

pyproject.toml
[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"]

Markers (e2e, slow, allow_network) are registered automatically by the plugin.


3. Root conftest.py

conftest.py
from bluefox_test import bluefox_test_setup
from app.models import Base

globals().update(bluefox_test_setup(base=Base))

Three lines. You now have: db, engine, client, app, and all create_* factory fixtures.

conftest.py
from bluefox_test import bluefox_test_setup
from app.models import Base

globals().update(
    bluefox_test_setup(
        base=Base,
        binds={
            "analytics": "analytics.models.AnalyticsBase",
            "events": "events.models.EventsBase",
        },
    )
)

This adds db_analytics and db_events session fixtures alongside the default db.

conftest.py
import pytest_asyncio
from bluefox_test import bluefox_test_setup
from app.models import Base

globals().update(bluefox_test_setup(base=Base, app_factory=None))

@pytest_asyncio.fixture
async def app(db):
    from my_app import build_app
    return build_app(session_override=db)

@pytest_asyncio.fixture
async def client(app):
    from httpx import ASGITransport, AsyncClient
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c

The db, engine, create_* fixtures still work. You're only replacing how the app consumes the session. See the custom app wiring guide.


4. Directory structure

src/
  users/
    models.py
    service.py
    routes.py
    tests/
      __init__.py
      factories.py       # factories + register() calls
      test_routes.py
      test_service.py
  orders/
    models.py
    tests/
      __init__.py
      factories.py
      test_routes.py
e2e/                     # black-box tests
  conftest.py
  seed.py
  test_auth.py
conftest.py              # root (Section 3)
pyproject.toml
Makefile
docker-compose.yml

Every tests/ directory needs an __init__.py for factory discovery.


5. Writing factories

Factories live in tests/factories.py within each domain. Define the class, call register(), done.

src/users/tests/factories.py
from bluefox_test import BaseFactory, Faker, register
from users.models import User, UserProfile


class UserFactory(BaseFactory):
    class Meta:
        model = User

    name = Faker("name")
    email = Faker("email")
    is_active = True

create_user = register(UserFactory)


class UserProfileFactory(BaseFactory):
    class Meta:
        model = UserProfile

    bio = Faker("paragraph")
    avatar_url = "https://example.com/avatar.png"
    # user is NOT declared — always pass explicitly

create_user_profile = register(UserProfileFactory)

create_user and create_user_profile are now available as fixtures in every test file. No imports needed in tests.

Explicit relationships

Don't use SubFactory for required relationships. Instead, always pass related objects explicitly in your tests. This is more predictable and avoids hidden database calls.

Cross-bind factories

For models in a non-default database:

create_analytics_event = register(AnalyticsEventFactory, session_fixture="db_analytics")

6. Writing tests

Naming convention

test_<function_or_endpoint>__<scenario>

Double underscore separates the unit from the scenario.

Rules

  • No classes. Functional tests only.
  • Never instantiate models directly — use create_* factories.
  • Never call .flush() or .commit() unless testing a DB constraint.
  • Always await factory calls — they're async.

Examples

src/users/tests/test_routes.py
async def test_get_user__returns_200_with_valid_id(client, create_user):
    user = await create_user(name="Hugo")
    response = await client.get(f"/users/{user.id}")
    assert response.status_code == 200
    assert response.json()["name"] == "Hugo"


async def test_get_user__returns_404_when_not_found(client):
    response = await client.get("/users/99999")
    assert response.status_code == 404

Explicit relationships

Pass related objects directly:

async def test_create_order__with_existing_user(client, create_user, create_order):
    user = await create_user(name="Hugo")
    order = await create_order(owner=user, status="pending")
    assert order.owner.name == "Hugo"
    assert order.owner_id == user.id

When you DO need .commit()

Only when testing actual database constraints:

async def test_create_user__enforces_unique_email(db, create_user):
    """Explicit commit: testing the UNIQUE constraint, not app validation."""
    await create_user(email="hugo@example.com")
    await db.commit()
    with pytest.raises(IntegrityError):
        await create_user(email="hugo@example.com")
        await db.commit()

7. Makefile

.PHONY: test test-parallel test-verbose test-domain e2e

test:
    pytest src/ -x --tb=short -q

test-parallel:
    pytest src/ -x --tb=short -q -n auto

test-verbose:
    pytest src/ -x --tb=long -v -s

test-domain:
    pytest src/$(DOMAIN)/tests/ -x -v

8. What you get for free

Feature Details
Container lifecycle Postgres + Redis start/stop automatically
Session isolation SAVEPOINT per test, rolled back after
session.commit() neutralization App code can commit freely; SAVEPOINT restarts
Async factories await create_user() returns an object with a real .id
Factory discovery tests/factories.py files auto-imported
Safety gates Host whitelist + test_ prefix enforcement
Factory linting Warns on missing factories and direct model usage
Markers e2e, slow, allow_network registered automatically
E2E auth Authenticate once, cache JWT, inject into clients

Next steps