Optional subfactories for Factory Boy
I often use a library called Factory Boy to create Django models in my tests. It works fine and I mostly like it but there's one thing that annoys me when it comes to nullable foreign keys on Django models. Let's set up some example models to explain:
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField(required=False)
class Article(models.Model):
author = models.ForeignKey("Author", on_delete=models.CASCADE, blank=True, null=True)
title = models.CharField(max_length=100)
Nothing extraordinary here, just two models linked by an optional foreign key. With Factory Boy (whose
importable module is called factory
for some reason), you create Factory
classes for
your models, and a foreign key relationship is typically represented using a SubFactory
like so:
from factory.django import DjangoModelFactory
from factory import SubFactory
from .models import Article, Author
class AuthorFactory(DjangoModelFactory):
class Meta:
model = Author
class ArticleFactory(DjangoModelFactory):
author = SubFactory(Author)
class Meta:
model = Article
That works great, and when you have those factories you can easily create two related instances in a single call to the factory:
>>> article = ArticleFactory.create(title="Test Article", author__name="Baptiste")
>>> article.author is None
False
>>> article.author.name
'Baptiste'
So far so expected. But a SubFactory
is not "smart" enough to know that the foreign key is
optional, and it will always created a related object, even if you don't ask for one:
>>> article = ArticleFactory.create(title="Test Article")
>>> article.author is None
False
>>> article.author.name
''
I'm not a big fan of this behavior, because I find it creates too many objects and often makes my tests more
verbose than I'd like. Looking for a solution, I soon found a
closed issue on github where the library
author suggests using something called a Trait
. But to be honest I never fully understood what
traits were, and I always thought there had to be a simpler way.
After some hours of reading the code of the library and trying out several approaches, I ended up with something which I think works pretty well:
from factory.django import DjangoModelFactory
from factory import SubFactory
from .models import Article, Author
class OptionalSubFactory(SubFactory):
def __init__(self, factory, **defaults):
super().__init__(factory)
self.defaults = defaults
def evaluate(self, instance, step, extra):
if not extra:
return None
kwargs = {**self.defaults, **extra}
return super().evaluate(instance, step, kwargs)
class AuthorFactory(DjangoModelFactory):
class Meta:
model = Author
class ArticleFactory(DjangoModelFactory):
author = OptionalSubFactory(Author)
class Meta:
model = Article
(In case you wanted to reuse this by any chance, the code in the above block is public domain.)
With this new OptionalSubFactory
, related objects are created when you specify any of their
arguments (just like with Factory
):
>>> article = ArticleFactory.create(title="Test Article", author__name="Baptiste")
>>> article.author is None
False
>>> article.author.name
'Baptiste'
But if you leave out its arguments, then no object is created and the foreign key is set to None
:
>>> article = ArticleFactory.create(title="Test Article")
>>> article.author is None
True
>>> article.author.name
Traceback (most recent call last):
File "<console>", line 1, in <module>
AttributeError: 'NoneType' object has no attribute 'name'
Et voilà! The one downside I found and which I couldn't find a fix for is that you
have to pass an explicit argument in order to create a related object. In some cases it would
be nice to be able to write something like ArticleFactory.create(profile=True)
and have the
profile
subfactory be called with all its default arguments. For now, if I want that behavior I
have to do something like:
>>> ArticleFactory.create(profile=ProfileFactory.create())
That's a bit annoying, but it's a tradeoff I can live with.