Skip to content

Fixtures

Dependency-aware database seeding with context-based loading strategies.

Overview

The fixtures module lets you define named fixtures with dependencies between them, then load them into the database in the correct order. Fixtures can be scoped to contexts (e.g. base data, testing data) so that only the relevant ones are loaded for each environment.

Defining fixtures

from fastapi_toolsets.fixtures import FixtureRegistry, Context

fixtures = FixtureRegistry()

@fixtures.register
def roles():
    return [
        Role(id=1, name="admin"),
        Role(id=2, name="user"),
    ]

@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users():
    return [
        User(id=1, username="alice", role_id=1),
        User(id=2, username="bob", role_id=2),
    ]

Dependencies declared via depends_on are resolved topologically — roles will always be loaded before test_users.

Loading fixtures

By context with load_fixtures_by_context:

from fastapi_toolsets.fixtures import load_fixtures_by_context

async with db_context() as session:
    await load_fixtures_by_context(session, fixtures, Context.TESTING)

Directly by name with load_fixtures:

from fastapi_toolsets.fixtures import load_fixtures

async with db_context() as session:
    await load_fixtures(session, fixtures, "roles", "test_users")

Both functions return a dict[str, list[...]] mapping each fixture name to the list of loaded instances.

Contexts

Context is an enum with predefined values:

Context Description
Context.BASE Core data required in all environments
Context.TESTING Data only loaded during tests
Context.DEVELOPMENT Data only loaded in development
Context.PRODUCTION Data only loaded in production

A fixture with no contexts defined takes Context.BASE by default.

Context.BASE fixtures are always included alongside whatever context you load or list — there's no way to load a non-base context in isolation:

# also loads any Context.BASE fixtures, even though only TESTING is requested
await load_fixtures_by_context(session, fixtures, Context.TESTING)

Custom contexts

Plain strings and any Enum subclass are accepted wherever a Context enum is expected.

from enum import Enum

class AppContext(str, Enum):
    STAGING = "staging"
    DEMO = "demo"

@fixtures.register(contexts=[AppContext.STAGING])
def staging_data():
    return [Config(key="feature_x", enabled=True)]

# loads staging_data plus any Context.BASE fixtures
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)

Default context for a registry

Pass contexts to FixtureRegistry to set a default for all fixtures registered in it:

testing_registry = FixtureRegistry(contexts=[Context.TESTING])

@testing_registry.register          # implicitly contexts=[Context.TESTING]
def test_orders():
    return [Order(id=1, total=99)]

Same fixture name, multiple context variants

The same fixture name may be registered under different (non-overlapping) context sets. When multiple contexts are loaded together, all matching variants are merged:

@fixtures.register(contexts=[Context.BASE])
def users():
    return [User(id=1, username="admin")]

@fixtures.register(contexts=[Context.TESTING])
def users():
    return [User(id=2, username="tester")]

# loads both admin and tester (Context.BASE is included automatically)
await load_fixtures_by_context(session, fixtures, Context.TESTING)

Registering two variants with overlapping context sets raises ValueError.

Load strategies

LoadStrategy controls how the fixture loader handles rows that already exist:

Strategy Description
LoadStrategy.INSERT Insert only, fail on duplicates
LoadStrategy.MERGE Insert or update on conflict (default)
LoadStrategy.SKIP_EXISTING Skip rows that already exist
await load_fixtures_by_context(
    session, fixtures, Context.BASE, strategy=LoadStrategy.SKIP_EXISTING
)

Merging registries

Split fixture definitions across modules and merge them:

from myapp.fixtures.dev import dev_fixtures
from myapp.fixtures.prod import prod_fixtures

fixtures = FixtureRegistry()
fixtures.include_registry(registry=dev_fixtures)
fixtures.include_registry(registry=prod_fixtures)

Fixtures with the same name are allowed as long as their context sets do not overlap. Conflicting contexts raise ValueError.

Looking up fixture instances

FixtureRegistry.obj retrieves a specific instance from a registered fixture by attribute value, looked up by name on the registry — useful when building cross-fixture depends_on relationships:

@fixtures.register(depends_on=["roles"])
def users():
    admin_role = fixtures.obj("roles", "name", "admin")
    return [User(id=1, username="alice", role_id=admin_role.id)]

Looking the fixture up by name (instead of importing the roles function directly) means fixture modules never need to import each other, which avoids circular imports in larger projects split across multiple files — the same reason depends_on takes fixture names rather than the functions themselves. The registry passed in must be the one that actually contains the fixture by load time; with a single shared registry this is automatic, but if you merge registries with include_registry, call obj/field on the merged registry.

FixtureRegistry.field is shorthand for pulling a single attribute (id by default):

@fixtures.register(depends_on=["roles"])
def users():
    return [User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))]

Both raise StopIteration if no matching instance is found, and KeyError if the fixture name isn't registered.

Pytest integration

Use register_fixtures to expose each fixture in your registry as an injectable pytest fixture named fixture_{name} by default:

# conftest.py
import pytest
from fastapi_toolsets.pytest import create_db_session, register_fixtures
from app.fixtures import registry
from app.models import Base

DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"

@pytest.fixture
async def db_session():
    async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session:
        yield session

register_fixtures(registry=registry, namespace=globals())
# test_users.py
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]):
    ...

The load order is resolved automatically from the depends_on declarations in your registry. Each generated fixture receives db_session as a dependency and returns the list of loaded model instances.

CLI integration

Fixtures can be triggered from the CLI. See the CLI module for setup instructions.


API Reference