Getting started¶
How to set up testing in a FastAPI + SQLAlchemy app using bluefox-test.
Prerequisites: Docker running. That's it.
1. Install¶
[dependency-groups]
test = [
"bluefox-test>=0.1.0,<1.0",
"pytest-xdist>=3.5", # optional: parallel workers
"playwright>=1.44", # optional: UI e2e tests
]
2. Pytest config¶
[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¶
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.
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.
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.
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:
6. Writing tests¶
Naming convention¶
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
awaitfactory calls — they're async.
Examples¶
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¶
- Walkthrough — complete Todo app example
- Reference — API documentation
- CI guide — GitHub Actions setup