diff --git a/django/db/models/options.py b/django/db/models/options.py index 5dd3a815de..2f1f930ec5 100644 --- a/django/db/models/options.py +++ b/django/db/models/options.py @@ -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 diff --git a/django/test/utils.py b/django/test/utils.py index 4e27af9004..73f30ef6ca 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -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 diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 75747b3110..31e795ee66 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -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) diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py index e6685a2272..c2251bebb4 100644 --- a/tests/test_utils/tests.py +++ b/tests/test_utils/tests.py @@ -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)