Skip to content

Model Factory

Saffier ships testing factories in saffier.testing for building model instances without writing large setup payloads by hand. Factories are Python-native and work with Saffier models, validators, relationship fields, and the SQLAlchemy Async runtime used by the test client.

Install the testing extra when your project does not already include the factory dependencies.

pip install "saffier[testing]"

Basic Factory

Define a ModelFactory subclass and point Meta.model at the Saffier model.

import saffier
from saffier.testing import FactoryField, ModelFactory

database = saffier.Database("sqlite+aiosqlite:///factory-docs.sqlite")
models = saffier.Registry(database=database)


class User(saffier.Model):
    id = saffier.IntegerField(primary_key=True)
    name = saffier.CharField(max_length=100)
    language = saffier.CharField(max_length=8, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")


user = UserFactory(name="Ada").build()

assert user.name == "Ada"
assert user.language

Passing keyword arguments to the factory instance overrides generated values. build() returns a model instance marked as loaded, but it does not write to the database.

Factory Fields

Use FactoryField for generated values. A string callback calls the matching Faker method. A callable callback receives the field, the factory context, and field-specific parameters.

from saffier.testing import FactoryField


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

    name = FactoryField(callback="name")

FactoryField(parameters=...) lets you pass default faker parameters. Build-time parameters override or extend those defaults for one generated object.

Relationships

Use SubFactory for one related object and ListSubFactory or ModelFactory.to_list_factory_field(...) for repeated related values.

from types import SimpleNamespace

import saffier
from saffier.testing import ListSubFactory, ModelFactory, SubFactory

database = saffier.Database("sqlite+aiosqlite:///factory-docs.sqlite")
models = saffier.Registry(database=database)


class User(saffier.Model):
    id = saffier.IntegerField(primary_key=True)
    name = saffier.CharField(max_length=100)

    class Meta:
        registry = models


class Team(saffier.Model):
    id = saffier.IntegerField(primary_key=True)
    name = saffier.CharField(max_length=100)
    owner = saffier.ForeignKey(User)

    class Meta:
        registry = models


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

    name = "Ada"


class TeamFactory(ModelFactory):
    class Meta:
        model = Team

    owner = SubFactory(UserFactory())


team = TeamFactory(name="Research").build()
members = ListSubFactory(UserFactory(), min=2, max=2)(
    context={
        "faker": SimpleNamespace(random_int=lambda min, max: 2),
        "callcounts": {},
    },
    parameters={},
)

assert team.owner.name == "Ada"
assert len(members) == 2

This keeps relationship construction inside the same factory context, including call counts and faker state.

Persisting Rows

Use build_and_save() in async tests when you want a row in the database. It uses Saffier's normal model save path and works well with DatabaseTestClient.force_rollback().

import anyio

import saffier
from saffier.testing import ModelFactory

database = saffier.Database("sqlite+aiosqlite:///factory-docs.sqlite")
models = saffier.Registry(database=database)


class User(saffier.Model):
    id = saffier.IntegerField(primary_key=True, autoincrement=True)
    name = saffier.CharField(max_length=100)

    class Meta:
        registry = models


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

    name = "Ada"


async def main() -> None:
    await models.create_all()
    try:
        async with database:
            user = await UserFactory().build_and_save()
            assert user.pk is not None
    finally:
        await models.drop_all()


if __name__ == "__main__":
    anyio.run(main)

Prefer build_and_save() over build(save=True) in async tests. The explicit async method avoids surprising nested-loop behavior and makes transaction ownership clear.

Useful Build Options

build(...) and build_and_save(...) accept:

  • parameters: per-field generator parameters.
  • overwrites: direct field values that bypass generation.
  • exclude: fields to omit from generated payloads.
  • database: database object to attach to the built model.
  • schema: schema name to attach to the built model.
  • exclude_autoincrement: whether autoincrement primary keys are generated.
  • callcounts: custom call-count storage for deterministic sequences.

The factory context exposes faker, exclude_autoincrement, depth, and callcounts. Custom callbacks can also store extra context keys as long as they do not collide with those built-in names.