【问题标题】:Simplify Django test set up with mock objects使用模拟对象简化 Django 测试设置
【发布时间】:2016-08-08 01:37:39
【问题描述】:

当我为我的 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()

【问题讨论】:

    标签: python django unit-testing


    【解决方案1】:

    花了一段时间才弄清楚要模拟什么,但这是可能的。您模拟了一对多字段管理器,但您必须在 class 上模拟它,而不是在 instance 上模拟它。这是模拟经理的测试的核心。

    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

    【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-03-11
    • 1970-01-01
    • 2011-07-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-09
    相关资源
    最近更新 更多