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

标签 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()

最佳答案

花了一段时间才弄清楚到底要模拟什么,但这是可能的。您模拟了一对多字段管理器,但必须在上模拟它,而不是在实例上模拟它。这是使用模拟管理器进行的测试的核心。

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 versiondjango-mock-queries library 是一个非常有用的工具。它在内存中提供了许多 QuerySet 功能。

我们将所有模拟测试放在名为 tests_mock.py 的文件中,这样我们就可以为所有应用程序运行所有模拟测试,如下所示:

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

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

关于python - 使用模拟对象简化 Django 测试设置,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/36658010/

相关文章:

python - 我可以在 Python 中使用 Google 音译吗?

django - 如何在 Django 的 CreateView 中测试 get_success_url

sql - Django:将数据从一个数据库复制到另一个数据库

Django admin 没有风格

c# - 移除用于生产的 InternalsVisibleTo

c# - 存储库中的 Moq 函数,以 lambda 表达式作为参数

unit-testing - 单元测试具有许多私有(private)方法的复杂类

python - 从字符串中删除字符的特定实例

python - 愚弄Python,它是从tty调用的

python - 如何在 Python/flask 中打开和搜索大文本文件