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:
- Factory Class Definition: Define your factory class, customizing fakers using
FactoryField
and setting default values for model fields. - 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.
- 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:
- Provide parameters to faker methods.
- 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
: Currentexclude_autoincrement
value.depth
: Current depth.callcounts
: Internal call count tracking (usefield.get_callcount()
andfield.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:
- Pass
build()
parameters as field parameters. Usemin
andmax
for 1-n relations. - Transform a
ModelFactory
to aFactoryField
usingto_factory_field
orto_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 toNone
.
Pass True
for equal distribution or a number (0-100) for bias.
Excluding Fields¶
Exclude fields in four ways:
- Provide a field with
exclude=True
. - Add the field name to the
exclude
parameter ofbuild
. - Raise
edgy.testing.exceptions.ExcludeValue
in a callback. - 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 theUserFactory
class. Edgy'sSubFactory
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.