Skip to content

ModelFactory: Streamlining Model Stubbing in Edgy

ModelFactory is a powerful tool in Edgy for generating model stubs based on fakers, simplifying your testing and development workflows.

The process involves three key steps:

  1. Factory Class Definition: Define your factory class, customizing fakers using FactoryField and setting default values for model fields.
  2. Factory Instance Creation: Create an instance of your factory, providing specific values to be used in the model. These values can be further customized or excluded later.
  3. Model Stub Generation: Utilize the build method to generate a stubbed model instance.

This sequence allows you to efficiently create and manipulate model instances for testing and other purposes.

Example:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")


user_factory = UserFactory(language="eng")

user_model_instance = user_factory.build()
# provide the name edgy
user_model_instance_with_name_edgy = user_factory.build(overwrites={"name": "edgy"})
# with saving
user_model_instance = user_factory.build(save=True)
# or the async variant
user_model_instance = edgy.run_sync(user_factory.build_and_save())

This creates a basic User model factory. Let's explore more advanced features.

Example: Excluding a field (name) using FactoryField:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")
    # remove the name field
    no_name = FactoryField(exclude=True, name="name")


user_factory = UserFactory()

user_model_instance = user_factory.build()
# you can however provide it explicit
user_model_instance_with_name = UserFactory(name="edgy").build()

Note

Each factory class has its own internal faker instance. To use a separate faker, provide it as the faker keyword parameter in the build method.

Parametrization

You can customize faker behavior in two ways:

  1. Provide parameters to faker methods.
  2. Provide a custom callable that can receive parameters.

When no callback is provided, Edgy uses mappings based on the field type name (e.g., CharField uses the "CharField" mapping).

Example: Customizing faker parameters:

from typing import Any

import edgy
from edgy.testing.factory import ModelFactory, FactoryField, ModelFactoryContext
from faker import Faker

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


def name_callback(
    field_instance: FactoryField, context: ModelFactoryContext, parameters: dict[str, Any]
) -> Any:
    return f"{parameters['first_name']} {parameters['last_name']}"


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

    # strings are an abbrevation for faker methods
    language = FactoryField(
        callback=lambda field_instance, context, parameters: context["faker"].language_code(
            **parameters
        )
    )
    name = FactoryField(
        callback=name_callback,
        # a ModelFactoryContext forwards to faker, so you can pretend it is a faker instance
        parameters={
            "first_name": lambda field_instance, fake_faker, parameters: fake_faker.first_name(),
            "last_name": lambda field_instance, fake_faker, parameters: fake_faker.last_name(),
        },
    )


user_factory = UserFactory()

# now the name is composed by two names
user_model = user_factory.build()

# now the name is composed by two names and both names are edgy
user_model = user_factory.build(parameters={"name": {"first_name": "edgy", "last_name": "edgy"}})

You can also override the field_type in FactoryField for different parametrization:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    password = edgy.fields.CharField(max_length=100)
    icon = edgy.fields.ImageField()

    class Meta:
        registry = models


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

    password = FactoryField(field_type="PasswordField")
    icon = FactoryField(field_type=edgy.fields.FileField)


user_factory = UserFactory()

# now the password uses the password field default mappings and for ImageField the FileField defaults
user_model = user_factory.build()

To override mappings for all subclasses, use the Meta.mappings attribute:

import edgy
from edgy.testing.factory.mappings import DEFAULT_MAPPING
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    password = edgy.fields.PasswordField(max_length=100)
    icon = edgy.fields.ImageField()

    class Meta:
        registry = models


class UserFactory(ModelFactory):
    class Meta:
        model = User
        mappings = {"ImageField": DEFAULT_MAPPING["FileField"], "PasswordField": None}


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


user_factory = UserFactory()

# now the password is excluded and for ImageField the FileField defaults are used
user_model = user_factory.build()

# this is inherited to children
user_model = UserSubFactory().build()

Setting a mapping to None disables stubbing by default. Re-enable it in subclasses:

import edgy
from edgy.testing.factory.mappings import DEFAULT_MAPPING
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


# the true user password simulator
def PasswordField_callback(field: FactoryField, context, parameters: dict[str, Any]) -> Any:
    return context["faker"].random_element(["company", "password123", "querty", "asdfg"])


class User(edgy.Model):
    password = edgy.fields.PasswordField(max_length=100)

    class Meta:
        registry = models


class UserFactory(ModelFactory):
    class Meta:
        model = User
        mappings = {"PasswordField": PasswordField_callback}


user_factory = UserFactory()

# now PasswordFields use a special custom mapping which provides common user passwords
user_model = user_factory.build()

Tip

Use the name parameter in FactoryField to avoid naming conflicts with model fields.

ModelFactoryContext

ModelFactoryContext replaces the faker argument, providing compatibility with faker while allowing access to context variables. It forwards __getattr__ calls to the internal faker instance and provides __getitem__ access to context items.

Known items:

  • faker: The faker instance.
  • exclude_autoincrement: Current exclude_autoincrement value.
  • depth: Current depth.
  • callcounts: Internal call count tracking (use field.get_callcount() and field.inc_callcount()).

You can store custom items in the context, ensuring they don't conflict with known items.

Saving

Save generated models using the save parameter or the build_and_save(...) method (recommended):

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")


user_factory = UserFactory(language="eng")

user_model_instance = await user_factory.build_and_save()

# or sync
user_model_instance = user_factory.build(save=True)

Warning

save=True can move saving to a subloop, causing issues with force_rollback or limited connections. Use build_and_save(...) in asynchronous contexts.

exclude_autoincrement

The exclude_autoincrement class parameter (default True) automatically excludes autoincrement columns:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")
    exclude_autoincrement = False


user_factory = UserFactory()

user_model = user_factory.build()
# now the id field is stubbed
assert hasattr(user_model, "id")

Set it to False to generate values for autoincrement fields.

Setting Database and Schema

Specify a different database or schema using class or instance variables (__using_schema__, database):

# class variables
class UserFactory(ModelFactory, database=database, __using_schema__="other"):
    ...

Or on the build method:

user = factory.build(database=database, schema="other")

Note

__using_schema__ = None in ModelFactory uses the model's default schema, while in database models, it selects the main schema.

Parametrizing Relation Fields

Parametrize relation fields (ForeignKey, ManyToMany, OneToOne, RelatedField) in two ways:

  1. Pass build() parameters as field parameters. Use min and max for 1-n relations.
  2. Transform a ModelFactory to a FactoryField using to_factory_field or to_list_factory_field(min=0, max=10).

RelatedFields can only be parametrized using the second method.

Example: Customizing a ForeignKey:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class Group(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)

    class Meta:
        registry = models


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)
    group = edgy.ForeignKey(Group)

    class Meta:
        registry = models


class GroupFactory(ModelFactory):
    class Meta:
        model = Group


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

    language = FactoryField(callback="language_code")
    group = GroupFactory().to_factory_field()


user_factory = UserFactory(language="eng")

user_model_instance = user_factory.build()

Example: Customizing a RelatedField, ManyToMany, or RefForeignKey:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class Group(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)

    class Meta:
        registry = models


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)
    group = edgy.ForeignKey(Group, related_name="users")

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")


class GroupFactory(ModelFactory):
    class Meta:
        model = Group

    users = UserFactory().to_factory_field()


group_factory = GroupFactory()

group_factory_instance = group_factory.build()

Warning

Relationship fields can lead to large graphs. Auto-generated factories exclude unparametrized ForeignKeys, etc., by default when they have defaults or can be null.

Special Parameters

Two special parameters are available for all fields:

  • randomly_unset: Randomly exclude a field value.
  • randomly_nullify: Randomly set a value to None.

Pass True for equal distribution or a number (0-100) for bias.

Excluding Fields

Exclude fields in four ways:

  1. Provide a field with exclude=True.
  2. Add the field name to the exclude parameter of build.
  3. Raise edgy.testing.exceptions.ExcludeValue in a callback.
  4. Use the exclude_autoincrement class variable or parameter.

Example: Excluding a field using exclude=True:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")
    # remove the name field
    no_name = FactoryField(exclude=True, name="name")


user_factory = UserFactory()

user_model_instance = user_factory.build()
# you can however provide it explicit
user_model_instance_with_name = UserFactory(name="edgy").build()

Example: Excluding fields using exclude parameter or ExcludeValue:

import edgy
from edgy.testing.exceptions import ExcludeValue
from edgy.testing.factory import ModelFactory, FactoryField

test_database = DatabaseTestClient(...)
models = edgy.Registry(database=...)


def callback(field_instance, context, parameters):
    raise ExcludeValue


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)

    class Meta:
        registry = models


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

    language = FactoryField(callback=callback)


user_factory = UserFactory()

user_model_instance = user_factory.build(exclude={"name"})

Sequences

Generate increasing sequences using call counts:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100)

    class Meta:
        registry = models


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

    name = FactoryField(
        callback=lambda field, context, parameters: f"user-{field.get_callcount()}"
    )


user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-1"
user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-2"
# reset
UserFactory.meta.callcounts.clear()

user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-1"

# provide a different callcounts dict
user_model_instance = UserFactory().build(callcounts={})
assert user_model_instance.name == "user-1"

Reset sequences using Factory.meta.callcounts.clear() or pass a custom callcounts dictionary.

Example: Generating even sequences:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100)

    class Meta:
        registry = models


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

    name = FactoryField(
        callback=lambda field, context, parameters: f"user-{field.inc_callcount()}"
    )


user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-2"
user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-4"
# reset
UserFactory.meta.callcounts.clear()

user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-2"

# provide a different callcounts dict
user_model_instance = UserFactory().build(callcounts={})
assert user_model_instance.name == "user-2"

Example: Generating odd sequences:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField

models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100)

    class Meta:
        registry = models


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

    name = FactoryField(
        callback=lambda field, context, parameters: f"user-{field.inc_callcount()}"
    )


# manipulate the callcounter. Requires callcounts argument as no context is available here
UserFactory.meta.fields["name"].inc_callcount(amount=-1, callcounts=UserFactory.meta.callcounts)
user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-1"
user_model_instance = UserFactory().build()
assert user_model_instance.name == "user-3"

SubFactories only increment the call counts of the entry point factory. Pass a custom callcounts dictionary to increment other factory call counts:

import edgy
from edgy.testing.factory import ModelFactory, FactoryField, SubFactory

models = edgy.Registry(database=...)


class Group(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)

    class Meta:
        registry = models


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100)
    group = edgy.ForeignKey(Group)

    class Meta:
        registry = models


class GroupFactory(ModelFactory):
    class Meta:
        model = Group

    name = FactoryField(
        callback=lambda field, context, parameters: f"group-{field.get_callcount()}"
    )


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

    name = FactoryField(
        callback=lambda field, context, parameters: f"user-{field.get_callcount()}"
    )

    group = SubFactory(GroupFactory())


user = UserFactory().build()
assert user.name == "user-1"
assert user.group.name == "group-1"

user = UserFactory().build()
assert user.name == "user-2"
assert user.group.name == "group-2"

# now group callcount is at 1 again because the callcounts of the GroupFactory are used
group = GroupFactory().build()
assert group.name == "group-1"

# now group name callcount is at 3 because the callcounts of the UserFactory are used
group = GroupFactory().build(callcounts=UserFactory.meta.callcounts)
assert group.name == "group-3"

# now we see the group name callcount has been bumped but not the one of user name because the field wasn't in the
# GroupFactory build tree
user = UserFactory().build()
assert user.name == "user-3"
assert user.group.name == "group-4"

Build & build_and_save

The build(...) and build_and_save(...) methods generate model instances with customizable parameters:

  • faker: Custom faker instance.
  • parameters: Field-specific parameters or callbacks.
  • overwrites: Direct value overrides.
  • exclude: Fields to exclude from stubbing.
  • database: Database to use.
  • schema: Schema to use.
  • exclude_autoincrement: Auto-exclude autoincrement columns.
  • save: Synchronously save the model.
  • callcounts: Custom call counts dictionary.

Example: Using build with parameters:

import edgy
from edgy.testing.client import DatabaseTestClient
from edgy.testing.factory import ModelFactory, FactoryField

test_database1 = DatabaseTestClient(...)
test_database2 = DatabaseTestClient(...)
models = edgy.Registry(database=...)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=100, null=True)
    language: str = edgy.CharField(max_length=200, null=True)
    password = edgy.fields.PasswordField(max_length=100)

    class Meta:
        registry = models


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

    language = FactoryField(callback="language_code")
    database = test_database1
    __using_schema__ = "test_schema1"


user_factory = UserFactory(language="eng")

user_model_instance = user_factory.build()


# customize later
user_model_instance_with_name_edgy = user_factory.build(
    overwrites={"name": "edgy"},
    parameters={"password": {"special_chars": False}},
    exclude={"language"},
)


# customize later, with different database and schema
user_model_instance_with_name_edgy = user_factory.build(
    overwrites={"name": "edgy"},
    parameters={"password": {"special_chars": False}},
    exclude={"language"},
    database=test_database2,
    schema="test_schema2",
)

Model Validation

Control model validation during factory generation using the model_validation class variable:

  • none: No validation.
  • warn: Warn for unsound definitions. (Default)
  • error: Raise exceptions for unsound definitions.
  • pedantic: Raise exceptions for Pydantic validation errors.

Note

Validation does not increment sequence call counts.

SubFactory

This is a special object that allows you to reuse factories previously created without any issues or concerns.

Imagine the following:

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

    name = "John Doe"
    language = "en"


class ProductFactory(ModelFactory):
    class Meta:
        model = Product

    name = "Product 1"
    rating = 5
    in_stock = True
    user = SubFactory("tests.factories.UserFactory") # String import

Did you see? With this SubFactory object, we can simply apply factories as a string with the location of the factory or passing the object directly, like the following:

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

    name = "John Doe"
    language = "en"


class ProductFactory(ModelFactory):
    class Meta:
        model = Product

    name = "Product 1"
    rating = 5
    in_stock = True
    user = SubFactory(UserFactory) # Object import
    user.parameters["randomly_nullify"] = True


class ItemFactory(ModelFactory):
    class Meta:
        model = Item

    product = SubFactory(ProductFactory)
    product.parameters["randomly_nullify"] = True

If the values are not supplied, Edgy takes care of generate them for you automatically anyway. For multiple values e.g. ManyToMany you can use ListSubFactory.

You can even parametrize them given that they are FactoryFields.

Tip

Effectively SubFactories are a nice wrapper around to_factory_field and to_list_factory_field which can pull in from other files.

Enhanced Explanation:

In the first ProductFactory example, user = SubFactory("tests.factories.UserFactory") demonstrates how to use a string to import a UserFactory. This is particularly useful when dealing with circular imports or when factories are defined in separate modules.

  • String Import: The string "tests.factories.UserFactory" specifies the fully qualified path to the UserFactory class. Edgy's SubFactory will dynamically import and instantiate this factory when needed. This approach is beneficial when your factory classes are organized into distinct modules, which is a common practice in larger projects.

  • Object Import: The second ProductFactory example, user = SubFactory(UserFactory), showcases direct object import. This is straightforward when the factory class is already in the current scope.

Both methods achieve the same result: creating a User instance within the Product factory. The choice between them depends on your project's structure and import preferences.