summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimon Charette <charette.s@gmail.com>2015-11-17 00:33:18 -0500
committerSimon Charette <charette.s@gmail.com>2016-01-06 20:00:07 -0500
commit7bb373e3097fe8e000e0bba005ff2dcfc18ab9a5 (patch)
tree86ebceca4da5db2c13a16d8db82ac856540e5690
parentb2cddeaaf448234713f393749ceb1dbe22101f39 (diff)
Refs #25746 -- Added a test utility to isolate inlined model registration.
Thanks to Tim for the review.
-rw-r--r--django/db/models/options.py4
-rw-r--r--django/test/utils.py68
-rw-r--r--docs/internals/contributing/writing-code/unit-tests.txt114
-rw-r--r--tests/test_utils/tests.py43
4 files changed, 226 insertions, 3 deletions
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)