Often when I'm writing tests for my Django project, I have to write a lot more code to set up database records than I do to actually test the object under test. Currently, I try to use test fixtures to store the related fields, but could I use mock objects to mock out the related tables that take so much work to set up?
Here's a trivial example. I want to test that a Person
object will spawn()
children according to its health.
In this case, a person's city is a required field, so I have to set up a city before I can create a person, even though the city is completely irrelevant to the spawn()
method. How could I simplify this test to not require creating a city? (In a typical example, the irrelevant but required set up could be tens or hundreds of records instead of just one.)
# Tested with Django 1.9.2
import sys
import django
from django.apps import apps
from django.apps.config import AppConfig
from django.conf import settings
from django.db import connections, models, DEFAULT_DB_ALIAS
from django.db.models.base import ModelBase
NAME = 'udjango'
def main():
setup()
class City(models.Model):
name = models.CharField(max_length=100)
class Person(models.Model):
name = models.CharField(max_length=50)
city = models.ForeignKey(City, related_name='residents')
health = models.IntegerField()
def spawn(self):
for i in range(self.health):
self.children.create(name='Child{}'.format(i))
class Child(models.Model):
parent = models.ForeignKey(Person, related_name='children')
name = models.CharField(max_length=255)
syncdb(City)
syncdb(Person)
syncdb(Child)
# A typical unit test would start here.
# The set up is irrelevant to the test, but required by the database.
city = City.objects.create(name='Vancouver')
# Actual test
dad = Person.objects.create(name='Dad', health=2, city=city)
dad.spawn()
# Validation
children = dad.children.all()
num_children = len(children)
assert num_children == 2, num_children
name2 = children[1].name
assert name2 == 'Child1', name2
# End of typical unit test.
print('Done.')
def setup():
DB_FILE = NAME + '.db'
with open(DB_FILE, 'w'):
pass # wipe the database
settings.configure(
DEBUG=True,
DATABASES={
DEFAULT_DB_ALIAS: {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': DB_FILE}},
LOGGING={'version': 1,
'disable_existing_loggers': False,
'formatters': {
'debug': {
'format': '%(asctime)s[%(levelname)s]'
'%(name)s.%(funcName)s(): %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S'}},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'debug'}},
'root': {
'handlers': ['console'],
'level': 'WARN'},
'loggers': {
"django.db": {"level": "WARN"}}})
app_config = AppConfig(NAME, sys.modules['__main__'])
apps.populate([app_config])
django.setup()
original_new_func = ModelBase.__new__
@staticmethod
def patched_new(cls, name, bases, attrs):
if 'Meta' not in attrs:
class Meta:
app_label = NAME
attrs['Meta'] = Meta
return original_new_func(cls, name, bases, attrs)
ModelBase.__new__ = patched_new
def syncdb(model):
""" Standard syncdb expects models to be in reliable locations.
Based on https://github.com/django/django/blob/1.9.3
/django/core/management/commands/migrate.py#L285
"""
connection = connections[DEFAULT_DB_ALIAS]
with connection.schema_editor() as editor:
editor.create_model(model)
main()
It took a while to figure out exactly what to mock, but it is possible. You mock out the one-to-many field manager, but you have to mock it out on the class, not on the instance. Here's the core of the test with a mocked out manager.
Person.children = Mock()
dad = Person(health=2)
dad.spawn()
num_children = len(Person.children.create.mock_calls)
assert num_children == 2, num_children
Person.children.create.assert_called_with(name='Child1')
One problem with that is that later tests will probably fail because you left the manager mocked out. Here's a full example with a context manager to mock out all the related fields, and then put them back when you leave the context.
# Tested with Django 1.9.2
from contextlib import contextmanager
from mock import Mock
import sys
import django
from django.apps import apps
from django.apps.config import AppConfig
from django.conf import settings
from django.db import connections, models, DEFAULT_DB_ALIAS
from django.db.models.base import ModelBase
NAME = 'udjango'
def main():
setup()
class City(models.Model):
name = models.CharField(max_length=100)
class Person(models.Model):
name = models.CharField(max_length=50)
city = models.ForeignKey(City, related_name='residents')
health = models.IntegerField()
def spawn(self):
for i in range(self.health):
self.children.create(name='Child{}'.format(i))
class Child(models.Model):
parent = models.ForeignKey(Person, related_name='children')
name = models.CharField(max_length=255)
syncdb(City)
syncdb(Person)
syncdb(Child)
# A typical unit test would start here.
# The irrelevant set up of a city and name is no longer required.
with mock_relations(Person):
dad = Person(health=2)
dad.spawn()
# Validation
num_children = len(Person.children.create.mock_calls)
assert num_children == 2, num_children
Person.children.create.assert_called_with(name='Child1')
# End of typical unit test.
print('Done.')
@contextmanager
def mock_relations(model):
model_name = model._meta.object_name
model.old_relations = {}
model.old_objects = model.objects
try:
for related_object in model._meta.related_objects:
name = related_object.name
model.old_relations[name] = getattr(model, name)
setattr(model, name, Mock(name='{}.{}'.format(model_name, name)))
setattr(model, 'objects', Mock(name=model_name + '.objects'))
yield
finally:
model.objects = model.old_objects
for name, relation in model.old_relations.iteritems():
setattr(model, name, relation)
del model.old_objects
del model.old_relations
def setup():
DB_FILE = NAME + '.db'
with open(DB_FILE, 'w'):
pass # wipe the database
settings.configure(
DEBUG=True,
DATABASES={
DEFAULT_DB_ALIAS: {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': DB_FILE}},
LOGGING={'version': 1,
'disable_existing_loggers': False,
'formatters': {
'debug': {
'format': '%(asctime)s[%(levelname)s]'
'%(name)s.%(funcName)s(): %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S'}},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'debug'}},
'root': {
'handlers': ['console'],
'level': 'WARN'},
'loggers': {
"django.db": {"level": "WARN"}}})
app_config = AppConfig(NAME, sys.modules['__main__'])
apps.populate([app_config])
django.setup()
original_new_func = ModelBase.__new__
@staticmethod
def patched_new(cls, name, bases, attrs):
if 'Meta' not in attrs:
class Meta:
app_label = NAME
attrs['Meta'] = Meta
return original_new_func(cls, name, bases, attrs)
ModelBase.__new__ = patched_new
def syncdb(model):
""" Standard syncdb expects models to be in reliable locations.
Based on https://github.com/django/django/blob/1.9.3
/django/core/management/commands/migrate.py#L285
"""
connection = connections[DEFAULT_DB_ALIAS]
with connection.schema_editor() as editor:
editor.create_model(model)
main()
You can mix mocked tests in with your regular Django tests, but we found that the Django tests got slower as we added more and more migrations. To skip the test database creation when we run the mocked tests, we added a mock_setup
module. It has to be imported before any Django models, and it does a minimal set up of the Django framework before the tests run. It also holds the mock_relations()
function.
from contextlib import contextmanager
from mock import Mock
import os
import django
from django.apps import apps
from django.db import connections
from django.conf import settings
if not apps.ready:
# Do the Django set up when running as a stand-alone unit test.
# That's why this module has to be imported before any Django models.
if 'DJANGO_SETTINGS_MODULE' not in os.environ:
os.environ['DJANGO_SETTINGS_MODULE'] = 'kive.settings'
settings.LOGGING['handlers']['console']['level'] = 'CRITICAL'
django.setup()
# Disable database access, these are pure unit tests.
db = connections.databases['default']
db['PASSWORD'] = '****'
db['USER'] = '**Database disabled for unit tests**'
@contextmanager
def mock_relations(*models):
""" Mock all related field managers to make pure unit tests possible.
with mock_relations(Dataset):
dataset = Dataset()
check = dataset.content_checks.create() # returns mock object
"""
try:
for model in models:
model_name = model._meta.object_name
model.old_relations = {}
model.old_objects = model.objects
for related_object in model._meta.related_objects:
name = related_object.name
model.old_relations[name] = getattr(model, name)
setattr(model, name, Mock(name='{}.{}'.format(model_name, name)))
model.objects = Mock(name=model_name + '.objects')
yield
finally:
for model in models:
old_objects = getattr(model, 'old_objects', None)
if old_objects is not None:
model.objects = old_objects
del model.old_objects
old_relations = getattr(model, 'old_relations', None)
if old_relations is not None:
for name, relation in old_relations.iteritems():
setattr(model, name, relation)
del model.old_relations
Now when the mock tests are run with the regular Django tests, they use the regular Django framework that's already set up. When the mock tests are run on their own, they do a minimal set up. That set up has evolved over time to help test new scenarios, so look at the latest version. One very useful tool is the django-mock-queries
library that provides a lot of the QuerySet
features in memory.
We put all our mock tests in files named tests_mock.py
, so we can run all the mock tests for all the apps like this:
python -m unittest discover -p 'tests_mock.py'
You can see an example mock test on GitHub.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With