Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Simplify Django test set up with mock objects

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()
like image 293
Don Kirkby Avatar asked Apr 15 '16 22:04

Don Kirkby


1 Answers

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.

like image 63
Don Kirkby Avatar answered Nov 14 '22 22:11

Don Kirkby