使用模拟对象简化Django测试设置

时间:2016-04-15 22:45:24

标签: python django unit-testing

通常当我为我的Django项目编写测试时,我必须编写更多代码来设置数据库记录,而不是实际测试被测对象。目前,我尝试使用测试夹具来存储相关的字段,但是我可以使用模拟对象来模拟需要花费大量工作来设置的相关表吗?

这是一个微不足道的例子。我想根据健康状况测试一个Person对象spawn()个孩子。

在这种情况下,一个人的城市是必填字段,所以我必须在创建一个人之前建立一个城市,即使该城市与spawn()方法完全无关。如何简化此测试以不需要创建城市? (在一个典型的例子中,不相关但需要的设置可能是数十或数百条记录而不是一条记录。)

# 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()

1 个答案:

答案 0 :(得分:3)

花了一段时间才弄明白要嘲笑什么,但这是可能的。你模拟了一对多的字段管理器,但你必须在上模拟它,而不是在实例上。以下是经过嘲弄的经理的测试核心。

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')

其中一个问题是后来的测试可能会失败,因为你让经理被嘲笑了。这是一个完整的示例,带有一个上下文管理器来模拟所有相关字段,然后在离开上下文时将它们放回去。

# 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()

您可以将模拟测试与常规Django测试混合使用,但我们发现随着我们添加越来越多的迁移,Django测试变慢了。要在运行模拟测试时跳过测试数据库创建,我们添加了mock_setup模块。它必须在任何Django模型之前导入,并且在测试运行之前它会对Django框架进行最小化设置。它还包含mock_relations()函数。

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

现在,当使用常规Django测试运行模拟测试时,他们使用已经设置的常规Django框架。当模拟测试单独运行时,它们会进行最小化设置。随着时间的推移,这个设置有助于测试新场景,所以请看latest version。一个非常有用的工具是django-mock-queries library,它在内存中提供了很多QuerySet个功能。

我们将所有模拟测试放在名为tests_mock.py的文件中,因此我们可以对所有应用程序运行所有模拟测试:

python -m unittest discover -p 'tests_mock.py'

您可以看到示例模拟测试on GitHub