A tour of genuine
Genuine is a fixture generator for model object inspired by ruby factory_bot and elixir ex_machina.
It makes it easy to create test data and associations for dataclasses. Install genuine and then you will be able to use it without configuration:
from dataclasses import dataclass
from genuine import build
@dataclass
class User:
name: str
age: int
admin: bool = False
user = build(User)
assert isinstance(user, User)
You can also generate a list of objects
In these examples, name
and age
are auto generated, infered from object annotations.
Will it is the default behavior, rendered values can be overriten:
But it doesn't seem natural that all objects have the same name, does it? You can some smart randomness to this attribute.
For example you can use the Cycle helper that will return a value cyclicly:
from genuine import Cycle
users = build_many(3, User, overrides={"name": Cycle(["John", "Dave"])})
assert users[0].name == "John"
assert users[1].name == "Dave"
assert users[2].name == "John"
Or use an fixture library like faker or mimesis that will make it smoothly for you:
from mimesis import Generic
from genuine import Computed
g = Generic()
name_generator = Computed(lambda: g.person.name())
users = build_many(3, User, overrides={"name": name_generator})
assert user[0].name != user[1].name != user[2].name
Persist data
Often you want to persist your model object somewhere. This is where the create
and create_many
helpers come from.
from genuine import create, Context
PERSISTED = []
def persist(instance: User, context: Context):
PERSISTED.append(instance)
user = create(User, storage=persist)
assert user in PERSISTED
Factories
OK, we saw that overrides
let you fine tune the value of attributes. but it's long and tiring to always include it, especially when you always want the same thing.
This is why you can define factories to teach genuine what plausible values it should use to build instances.
For example, those 2 scenarios have the same result:
name_generator = Cycle(["John", "Dave"])
user = build(User, overrides={"name": name_generator})
user = build(User, overrides={"name": name_generator})
user = build(User, overrides={"name": name_generator})
user = build(User, overrides={"name": name_generator})
from genuine import define_factory
with define_factory(User) as factory:
factory.set("name", Cycle(["John", "Dave"]))
user = build(User)
user = build(User)
user = build(User)
user = build(User)
Defining factories allows you to reuse them everywhere in your tests.
But that's not all. instances sometimes have to be like this or like that.
Factories allow you to also define different traits
for the same model object:
with define_factory(User) as factory:
factory.set("name", name_generator)
with factory.trait("regular") as trait:
trait.set("admin", False)
with factory.trait("admin") as trait:
trait.set("admin", True)
assert build(User, "regular").admin is False
assert build(User, "admin").admin is True
Traits are a mechanism when there is little things that change.
You can also define derived factories using derived_factory
, for example:
with define_factory(User) as main:
main.set("name", name_generator)
main.set("admin", False)
with main.derived_factory("admin") as derived:
derived.set("admin", True)
assert build(User).admin is False
assert build(User, "admin").admin is True
Finally you are able to define specialized factories for the same model:
with define_factory(User) as factory:
...
with define_factory(User, "admin") as factory:
...
assert build(User).admin is False
assert build(User, "admin").admin is True
Context and transient
Factories let you declare some context data that will help you to fine tune you instances.
Let's say sometime you need to uppercase name:
with define_factory(User) as factory:
factory.set("name", Computed(lambda uppercased: "BIG JOHN" if uppercased else "Regular John"))
with factory.transient() as transient:
transient.set("uppercased", False)
assert build(User).name == "Regular John"
assert build(User, overrides={"uppercased": True}).name== "BIG JOHN"
Those instance attributes and transient data are passed into Context
object.
This object will help you to define complexe scenarios. It is exposed into helper functions like persist, hooks and refinement.
For example, the previous factory will generate those Context:
build(User, overrides={"uppercased": False}) # --> Context({"name": "Regular John", "uppercased": False})
build(User, overrides={"uppercased": True}) # --> Context({"name": "Regular John", "uppercased": True})
Nested model objects
Comment have commenter, right? This is what we call a sub object.
With factory bot, those objects can be declared easily to, with associations:
@dataclass
class Comment:
commenter: Author
with define_factory(Comment) as factory:
factory.associate("commenter", User)
commenter = build(Comment).commenter
assert isinstance(commenter, User)
You can override nested objects too:
comment = build(Comment, override={"commenter": Overrides({"name": "Mickey"})})
assert comment.commenter.name == "Mickey"
Note that by default, nested objects are always created. This behavior can be configured within association
, or with Overrides
object, using Strategy
enum.
with define_factory(Comment) as factory:
factory.associate("commenter", User, strategy=Strategy.BUILD)
Hooks and refinement
You can declare some hooks that will be applied during the construction of the object.
with define_factory(User) as factory:
@factory.hook("after_build")
def _(instance: User, context: Context):
print("called after_build")
@factory.hook("before_create")
def _(instance: User, context: Context):
print("called before_create")
@factory.hook("after_create")
def _(instance: User, context: Context):
print("called after_create")
create(User)
# prints
# called after_build
# called before_create
# called after_create
And you can define a refinement function at the end of the function