Commit 7bb373e3 authored by Simon Charette's avatar Simon Charette
Browse files

Refs #25746 -- Added a test utility to isolate inlined model registration.

Thanks to Tim for the review.
parent b2cddeaa
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -69,6 +69,8 @@ class Options(object):
                          'local_concrete_fields', '_forward_fields_map'}
    REVERSE_PROPERTIES = {'related_objects', 'fields_map', '_relation_tree'}

    default_apps = apps

    def __init__(self, meta, app_label=None):
        self._get_fields_cache = {}
        self.local_fields = []
@@ -124,7 +126,7 @@ class Options(object):
        self.related_fkey_lookups = []

        # A custom app registry to use, if you're making a separate model set.
        self.apps = apps
        self.apps = self.default_apps

        self.default_related_name = None

+68 −0
Original line number Diff line number Diff line
@@ -9,10 +9,12 @@ from unittest import skipIf, skipUnless
from xml.dom.minidom import Node, parseString

from django.apps import apps
from django.apps.registry import Apps
from django.conf import UserSettingsHolder, settings
from django.core import mail
from django.core.signals import request_started
from django.db import reset_queries
from django.db.models.options import Options
from django.http import request
from django.template import Template
from django.test.signals import setting_changed, template_rendered
@@ -640,3 +642,69 @@ class LoggingCaptureMixin(object):

    def tearDown(self):
        self.logger.handlers[0].stream = self.old_stream


class isolate_apps(object):
    """
    Act as either a decorator or a context manager to register models defined
    in its wrapped context to an isolated registry.

    The list of installed apps the isolated registry should contain must be
    passed as arguments.

    Two optional keyword arguments can be specified:

    `attr_name`: attribute assigned the isolated registry if used as a class
                 decorator.

    `kwarg_name`: keyword argument passing the isolated registry to the
                  decorated method.
    """

    def __init__(self, *installed_apps, **kwargs):
        self.installed_apps = installed_apps
        self.attr_name = kwargs.pop('attr_name', None)
        self.kwarg_name = kwargs.pop('kwarg_name', None)

    def enable(self):
        self.old_apps = Options.default_apps
        apps = Apps(self.installed_apps)
        setattr(Options, 'default_apps', apps)
        return apps

    def disable(self):
        setattr(Options, 'default_apps', self.old_apps)

    def __enter__(self):
        return self.enable()

    def __exit__(self, exc_type, exc_value, traceback):
        self.disable()

    def __call__(self, decorated):
        if isinstance(decorated, type):
            # A class is decorated
            decorated_setUp = decorated.setUp
            decorated_tearDown = decorated.tearDown

            def setUp(inner_self):
                apps = self.enable()
                if self.attr_name:
                    setattr(inner_self, self.attr_name, apps)
                decorated_setUp(inner_self)

            def tearDown(inner_self):
                decorated_tearDown(inner_self)
                self.disable()

            decorated.setUp = setUp
            decorated.tearDown = tearDown
            return decorated
        else:
            @wraps(decorated)
            def inner(*args, **kwargs):
                with self as apps:
                    if self.kwarg_name:
                        kwargs[self.kwarg_name] = apps
                    return decorated(*args, **kwargs)
            return inner
+114 −0
Original line number Diff line number Diff line
@@ -303,3 +303,117 @@ purpose.

    Support for running tests in parallel and the ``--parallel`` option were
    added.

Tips for writing tests
----------------------

.. highlight:: python

Isolating model registration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

To avoid polluting the global :attr:`~django.apps.apps` registry and prevent
unnecessary table creation, models defined in a test method should be bound to
a temporary ``Apps`` instance::

    from django.apps.registry import Apps
    from django.db import models
    from django.test import SimpleTestCase

    class TestModelDefinition(SimpleTestCase):
        def test_model_definition(self):
            test_apps = Apps(['app_label'])

            class TestModel(models.Model):
                class Meta:
                    apps = test_apps
            ...

.. function:: django.test.utils.isolate_apps(*app_labels, attr_name=None, kwarg_name=None)

.. versionadded:: 1.10

Since this pattern involves a lot of boilerplate, Django provides the
:func:`~django.test.utils.isolate_apps` decorator. It's used like this::

    from django.db import models
    from django.test import SimpleTestCase
    from django.test.utils import isolate_apps

    class TestModelDefinition(SimpleTestCase):
        @isolate_apps('app_label')
        def test_model_definition(self):
            class TestModel(models.Model):
                pass
            ...

.. admonition:: Setting ``app_label``

    Models defined in a test method with no explicit
    :attr:`~django.db.models.Options.app_label` are automatically assigned the
    label of the app in which their test class is located.

    In order to make sure the models defined within the context of
    :func:`~django.test.utils.isolate_apps` instances are correctly
    installed, you should pass the set of targeted ``app_label`` as arguments:

    .. snippet::
        :filename: tests/app_label/tests.py

        from django.db import models
        from django.test import SimpleTestCase
        from django.test.utils import isolate_apps

        class TestModelDefinition(SimpleTestCase):
            @isolate_apps('app_label', 'other_app_label')
            def test_model_definition(self):
                # This model automatically receives app_label='app_label'
                class TestModel(models.Model):
                    pass

                class OtherAppModel(models.Model):
                    class Meta:
                        app_label = 'other_app_label'
                ...

The decorator can also be applied to classes::

    from django.db import models
    from django.test import SimpleTestCase
    from django.test.utils import isolate_apps

    @isolate_apps('app_label')
    class TestModelDefinition(SimpleTestCase):
        def test_model_definition(self):
            class TestModel(models.Model):
                pass
            ...

The temporary ``Apps`` instance used to isolate model registration can be
retrieved as an attribute when used as a class decorator by using the
``attr_name`` parameter::

    from django.db import models
    from django.test import SimpleTestCase
    from django.test.utils import isolate_apps

    @isolate_apps('app_label', attr_name='apps')
    class TestModelDefinition(SimpleTestCase):
        def test_model_definition(self):
            class TestModel(models.Model):
                pass
            self.assertIs(self.apps.get_model('app_label', 'TestModel'), TestModel)

Or as an argument on the test method when used as a method decorator by using
the ``kwarg_name`` parameter::

    from django.db import models
    from django.test import SimpleTestCase
    from django.test.utils import isolate_apps

    class TestModelDefinition(SimpleTestCase):
        @isolate_apps('app_label', kwarg_name='apps')
        def test_model_definition(self, apps):
            class TestModel(models.Model):
                pass
            self.assertIs(apps.get_model('app_label', 'TestModel'), TestModel)
+41 −2
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@ from django.conf.urls import url
from django.contrib.staticfiles.finders import get_finder, get_finders
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.storage import default_storage
from django.db import connection, router
from django.db import connection, models, router
from django.forms import EmailField, IntegerField
from django.http import HttpResponse
from django.template.loader import render_to_string
@@ -17,7 +17,9 @@ from django.test import (
    skipUnlessDBFeature,
)
from django.test.html import HTMLParseError, parse_html
from django.test.utils import CaptureQueriesContext, override_settings
from django.test.utils import (
    CaptureQueriesContext, isolate_apps, override_settings,
)
from django.urls import NoReverseMatch, reverse
from django.utils import six
from django.utils._os import abspathu
@@ -1029,3 +1031,40 @@ class AllowedDatabaseQueriesTests(SimpleTestCase):

    def test_allowed_database_queries(self):
        Car.objects.first()


@isolate_apps('test_utils', attr_name='class_apps')
class IsolatedAppsTests(SimpleTestCase):
    def test_installed_apps(self):
        self.assertEqual([app_config.label for app_config in self.class_apps.get_app_configs()], ['test_utils'])

    def test_class_decoration(self):
        class ClassDecoration(models.Model):
            pass
        self.assertEqual(ClassDecoration._meta.apps, self.class_apps)

    @isolate_apps('test_utils', kwarg_name='method_apps')
    def test_method_decoration(self, method_apps):
        class MethodDecoration(models.Model):
            pass
        self.assertEqual(MethodDecoration._meta.apps, method_apps)

    def test_context_manager(self):
        with isolate_apps('test_utils') as context_apps:
            class ContextManager(models.Model):
                pass
        self.assertEqual(ContextManager._meta.apps, context_apps)

    @isolate_apps('test_utils', kwarg_name='method_apps')
    def test_nested(self, method_apps):
        class MethodDecoration(models.Model):
            pass
        with isolate_apps('test_utils') as context_apps:
            class ContextManager(models.Model):
                pass
            with isolate_apps('test_utils') as nested_context_apps:
                class NestedContextManager(models.Model):
                    pass
        self.assertEqual(MethodDecoration._meta.apps, method_apps)
        self.assertEqual(ContextManager._meta.apps, context_apps)
        self.assertEqual(NestedContextManager._meta.apps, nested_context_apps)