Browse Source

Added modify_settings to alter settings containing lists of values.

Aymeric Augustin 11 years ago
parent
commit
5241763c81

+ 2 - 2
django/test/__init__.py

@@ -8,10 +8,10 @@ from django.test.testcases import (
     SimpleTestCase, LiveServerTestCase, skipIfDBFeature,
     skipUnlessDBFeature
 )
-from django.test.utils import override_settings
+from django.test.utils import modify_settings, override_settings
 
 __all__ = [
     'Client', 'RequestFactory', 'TestCase', 'TransactionTestCase',
     'SimpleTestCase', 'LiveServerTestCase', 'skipIfDBFeature',
-    'skipUnlessDBFeature', 'override_settings',
+    'skipUnlessDBFeature', 'modify_settings', 'override_settings',
 ]

+ 20 - 7
django/test/testcases.py

@@ -32,7 +32,7 @@ from django.test.client import Client
 from django.test.html import HTMLParseError, parse_html
 from django.test.signals import setting_changed, template_rendered
 from django.test.utils import (CaptureQueriesContext, ContextList,
-    override_settings, compare_xml)
+    override_settings, modify_settings, compare_xml)
 from django.utils.encoding import force_text
 from django.utils import six
 from django.utils.six.moves.urllib.parse import urlsplit, urlunsplit, urlparse, unquote
@@ -164,7 +164,8 @@ class SimpleTestCase(unittest.TestCase):
     # The class we'll use for the test client self.client.
     # Can be overridden in derived classes.
     client_class = Client
-    _custom_settings = None
+    _overridden_settings = None
+    _modified_settings = None
 
     def __call__(self, result=None):
         """
@@ -197,9 +198,12 @@ class SimpleTestCase(unittest.TestCase):
         * If the class has a 'urls' attribute, replace ROOT_URLCONF with it.
         * Clearing the mail test outbox.
         """
-        if self._custom_settings:
-            self._overridden = override_settings(**self._custom_settings)
-            self._overridden.enable()
+        if self._overridden_settings:
+            self._overridden_context = override_settings(**self._overridden_settings)
+            self._overridden_context.enable()
+        if self._modified_settings:
+            self._modified_context = modify_settings(self._modified_settings)
+            self._modified_context.enable()
         self.client = self.client_class()
         self._urlconf_setup()
         mail.outbox = []
@@ -217,8 +221,10 @@ class SimpleTestCase(unittest.TestCase):
         * Putting back the original ROOT_URLCONF if it was changed.
         """
         self._urlconf_teardown()
-        if self._custom_settings:
-            self._overridden.disable()
+        if self._modified_settings:
+            self._modified_context.disable()
+        if self._overridden_settings:
+            self._overridden_context.disable()
 
     def _urlconf_teardown(self):
         set_urlconf(None)
@@ -233,6 +239,13 @@ class SimpleTestCase(unittest.TestCase):
         """
         return override_settings(**kwargs)
 
+    def modify_settings(self, **kwargs):
+        """
+        A context manager that temporarily applies changes a list setting and
+        reverts back to the original value when exiting the context.
+        """
+        return modify_settings(**kwargs)
+
     def assertRedirects(self, response, expected_url, status_code=302,
                         target_status_code=200, host=None, msg_prefix='',
                         fetch_redirect_response=True):

+ 63 - 12
django/test/utils.py

@@ -24,8 +24,10 @@ from django.utils.translation import deactivate
 
 
 __all__ = (
-    'Approximate', 'ContextList', 'get_runner', 'override_settings',
-    'requires_tz_support', 'setup_test_environment', 'teardown_test_environment',
+    'Approximate', 'ContextList', 'get_runner',
+    'modify_settings', 'override_settings',
+    'requires_tz_support',
+    'setup_test_environment', 'teardown_test_environment',
 )
 
 RESTORE_LOADERS_ATTR = '_original_template_source_loaders'
@@ -191,8 +193,6 @@ class override_settings(object):
     """
     def __init__(self, **kwargs):
         self.options = kwargs
-        # Special case that requires updating the app cache, a core feature.
-        self.installed_apps = self.options.get('INSTALLED_APPS')
 
     def __enter__(self):
         self.enable()
@@ -207,11 +207,7 @@ class override_settings(object):
                 raise Exception(
                     "Only subclasses of Django SimpleTestCase can be decorated "
                     "with override_settings")
-            if test_func._custom_settings:
-                test_func._custom_settings = dict(
-                    test_func._custom_settings, **self.options)
-            else:
-                test_func._custom_settings = self.options
+            self.save_options(test_func)
             return test_func
         else:
             @wraps(test_func)
@@ -220,14 +216,22 @@ class override_settings(object):
                     return test_func(*args, **kwargs)
         return inner
 
+    def save_options(self, test_func):
+        if test_func._overridden_settings is None:
+            test_func._overridden_settings = self.options
+        else:
+            # Duplicate dict to prevent subclasses from altering their parent.
+            test_func._overridden_settings = dict(
+                test_func._overridden_settings, **self.options)
+
     def enable(self):
         override = UserSettingsHolder(settings._wrapped)
         for key, new_value in self.options.items():
             setattr(override, key, new_value)
         self.wrapped = settings._wrapped
         settings._wrapped = override
-        if self.installed_apps is not None:
-            app_cache.set_installed_apps(self.installed_apps)
+        if 'INSTALLED_APPS' in self.options:
+            app_cache.set_installed_apps(settings.INSTALLED_APPS)
         for key, new_value in self.options.items():
             setting_changed.send(sender=settings._wrapped.__class__,
                                  setting=key, value=new_value, enter=True)
@@ -235,7 +239,7 @@ class override_settings(object):
     def disable(self):
         settings._wrapped = self.wrapped
         del self.wrapped
-        if self.installed_apps is not None:
+        if 'INSTALLED_APPS' in self.options:
             app_cache.unset_installed_apps()
         for key in self.options:
             new_value = getattr(settings, key, None)
@@ -243,6 +247,53 @@ class override_settings(object):
                                  setting=key, value=new_value, enter=False)
 
 
+class modify_settings(override_settings):
+    """
+    Like override_settings, but makes it possible to append, prepend or remove
+    items instead of redefining the entire list.
+    """
+    def __init__(self, *args, **kwargs):
+        if args:
+            # Hack used when instaciating from SimpleTestCase._pre_setup.
+            assert not kwargs
+            self.operations = args[0]
+        else:
+            assert not args
+            self.operations = list(kwargs.items())
+
+    def save_options(self, test_func):
+        if test_func._modified_settings is None:
+            test_func._modified_settings = self.operations
+        else:
+            # Duplicate list to prevent subclasses from altering their parent.
+            test_func._modified_settings = list(
+                test_func._modified_settings) + self.operations
+
+    def enable(self):
+        self.options = {}
+        for name, operations in self.operations:
+            try:
+                # When called from SimpleTestCase._pre_setup, values may be
+                # overridden several times; cumulate changes.
+                value = self.options[name]
+            except KeyError:
+                value = list(getattr(settings, name, []))
+            for action, items in operations.items():
+                # items my be a single value or an iterable.
+                if isinstance(items, six.string_types):
+                    items = [items]
+                if action == 'append':
+                    value = value + [item for item in items if item not in value]
+                elif action == 'prepend':
+                    value = [item for item in items if item not in value] + value
+                elif action == 'remove':
+                    value = [item for item in value if item not in items]
+                else:
+                    raise ValueError("Unsupported action: %s" % action)
+            self.options[name] = value
+        super(modify_settings, self).enable()
+
+
 def compare_xml(want, got):
     """Tries to do a 'xml-comparison' of want and got.  Plain string
     comparison doesn't always work because, for example, attribute

+ 86 - 24
docs/topics/testing/overview.txt

@@ -1335,7 +1335,7 @@ Overriding settings
 
 For testing purposes it's often useful to change a setting temporarily and
 revert to the original value after running the testing code. For this use case
-Django provides a standard Python context manager (see :pep:`343`)
+Django provides a standard Python context manager (see :pep:`343`) called
 :meth:`~django.test.SimpleTestCase.settings`, which can be used like this::
 
     from django.test import TestCase
@@ -1356,12 +1356,41 @@ Django provides a standard Python context manager (see :pep:`343`)
 This example will override the :setting:`LOGIN_URL` setting for the code
 in the ``with`` block and reset its value to the previous state afterwards.
 
+.. method:: SimpleTestCase.modify_settings
+
+.. versionadded:: 1.7
+
+It can prove unwieldy to redefine settings that contain a list of values. In
+practice, adding or removing values is often sufficient. The
+:meth:`~django.test.SimpleTestCase.modify_settings` context manager makes it
+easy::
+
+    from django.test import TestCase
+
+    class MiddlewareTestCase(TestCase):
+
+        def test_cache_middleware(self):
+            with self.modify_settings(MIDDLEWARE_CLASSES={
+                'append': 'django.middleware.cache.FetchFromCacheMiddleware',
+                'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
+                'remove': [
+                    'django.contrib.sessions.middleware.SessionMiddleware',
+                    'django.contrib.auth.middleware.AuthenticationMiddleware',
+                    'django.contrib.messages.middleware.MessageMiddleware',
+                ],
+            }):
+                response = self.client.get('/')
+                # ...
+
+For each action, you can supply either a list of values or a string. When the
+value already exists in the list, ``append`` and ``prepend`` have no effect;
+neither does ``remove`` when the value doesn't exist.
+
 .. function:: override_settings
 
-In case you want to override a setting for just one test method or even the
-whole :class:`~django.test.TestCase` class, Django provides the
-:func:`~django.test.override_settings` decorator (see :pep:`318`). It's
-used like this::
+In case you want to override a setting for a test method, Django provides the
+:func:`~django.test.override_settings` decorator (see :pep:`318`). It's used
+like this::
 
     from django.test import TestCase, override_settings
 
@@ -1372,7 +1401,7 @@ used like this::
             response = self.client.get('/sekrit/')
             self.assertRedirects(response, '/other/login/?next=/sekrit/')
 
-The decorator can also be applied to test case classes::
+The decorator can also be applied to :class:`~django.test.TestCase` classes::
 
     from django.test import TestCase, override_settings
 
@@ -1385,17 +1414,50 @@ The decorator can also be applied to test case classes::
 
 .. versionchanged:: 1.7
 
-    Previously, ``override_settings`` was imported from
-    ``django.test.utils``.
+    Previously, ``override_settings`` was imported from ``django.test.utils``.
+
+.. function:: modify_settings
+
+.. versionadded:: 1.7
+
+Likewise, Django provides the :func:`~django.test.modify_settings`
+decorator::
+
+    from django.test import TestCase, modify_settings
+
+    class MiddlewareTestCase(TestCase):
+
+        @modify_settings(MIDDLEWARE_CLASSES={
+            'append': 'django.middleware.cache.FetchFromCacheMiddleware',
+            'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
+        })
+        def test_cache_middleware(self):
+            response = self.client.get('/')
+            # ...
+
+The decorator can also be applied to test case classes::
+
+    from django.test import TestCase, modify_settings
+
+    @modify_settings(MIDDLEWARE_CLASSES={
+        'append': 'django.middleware.cache.FetchFromCacheMiddleware',
+        'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
+    })
+    class MiddlewareTestCase(TestCase):
+
+        def test_cache_middleware(self):
+            response = self.client.get('/')
+            # ...
 
 .. note::
 
-    When given a class, the decorator modifies the class directly and
-    returns it; it doesn't create and return a modified copy of it.  So if
-    you try to tweak the above example to assign the return value to a
-    different name than ``LoginTestCase``, you may be surprised to find that
-    the original ``LoginTestCase`` is still equally affected by the
-    decorator.
+    When given a class, these decorators modify the class directly and return
+    it; they don't create and return a modified copy of it. So if you try to
+    tweak the above examples to assign the return value to a different name
+    than ``LoginTestCase`` or ``MiddlewareTestCase``, you may be surprised to
+    find that the original test case classes are still equally affected by the
+    decorator. For a given class, :func:`~django.test.modify_settings` is
+    always applied after :func:`~django.test.override_settings`.
 
 .. warning::
 
@@ -1403,17 +1465,17 @@ The decorator can also be applied to test case classes::
     initialization of Django internals. If you change them with
     ``override_settings``, the setting is changed if you access it via the
     ``django.conf.settings`` module, however, Django's internals access it
-    differently. Effectively, using ``override_settings`` with these settings
-    is probably not going to do what you expect it to do.
+    differently. Effectively, using :func:`~django.test.override_settings` or
+    :func:`~django.test.modify_settings` with these settings is probably not
+    going to do what you expect it to do.
 
-    We do not recommend using ``override_settings`` with :setting:`DATABASES`.
-    Using ``override_settings`` with :setting:`CACHES` is possible, but a bit
-    tricky if you are using internals that make using of caching, like
+    We do not recommend altering the :setting:`DATABASES` setting. Altering
+    the :setting:`CACHES` setting is possible, but a bit tricky if you are
+    using internals that make using of caching, like
     :mod:`django.contrib.sessions`. For example, you will have to reinitialize
     the session backend in a test that uses cached sessions and overrides
     :setting:`CACHES`.
 
-
 You can also simulate the absence of a setting by deleting it after settings
 have been overridden, like this::
 
@@ -1423,10 +1485,10 @@ have been overridden, like this::
         ...
 
 When overriding settings, make sure to handle the cases in which your app's
-code uses a cache or similar feature that retains state even if the
-setting is changed. Django provides the
-:data:`django.test.signals.setting_changed` signal that lets you register
-callbacks to clean up and otherwise reset state when settings are changed.
+code uses a cache or similar feature that retains state even if the setting is
+changed. Django provides the :data:`django.test.signals.setting_changed`
+signal that lets you register callbacks to clean up and otherwise reset state
+when settings are changed.
 
 Django itself uses this signal to reset various data:
 

+ 57 - 4
tests/settings_tests/tests.py

@@ -6,19 +6,57 @@ from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.http import HttpRequest
 from django.test import SimpleTestCase, TransactionTestCase, TestCase, signals
-from django.test.utils import override_settings
+from django.test.utils import modify_settings, override_settings
 from django.utils import six
 
 
-@override_settings(TEST='override', TEST_OUTER='outer')
+@modify_settings(ITEMS={
+    'prepend': ['b'],
+    'append': ['d'],
+    'remove': ['a', 'e']
+})
+@override_settings(ITEMS=['a', 'c', 'e'], ITEMS_OUTER=[1, 2, 3],
+                   TEST='override', TEST_OUTER='outer')
 class FullyDecoratedTranTestCase(TransactionTestCase):
 
     available_apps = []
 
     def test_override(self):
+        self.assertListEqual(settings.ITEMS, ['b', 'c', 'd'])
+        self.assertListEqual(settings.ITEMS_OUTER, [1, 2, 3])
         self.assertEqual(settings.TEST, 'override')
         self.assertEqual(settings.TEST_OUTER, 'outer')
 
+    @modify_settings(ITEMS={
+        'append': ['e', 'f'],
+        'prepend': ['a'],
+        'remove': ['d', 'c'],
+    })
+    def test_method_list_override(self):
+        self.assertListEqual(settings.ITEMS, ['a', 'b', 'e', 'f'])
+        self.assertListEqual(settings.ITEMS_OUTER, [1, 2, 3])
+
+    @modify_settings(ITEMS={
+        'append': ['b'],
+        'prepend': ['d'],
+        'remove': ['a', 'c', 'e'],
+    })
+    def test_method_list_override_no_ops(self):
+        self.assertListEqual(settings.ITEMS, ['b', 'd'])
+
+    @modify_settings(ITEMS={
+        'append': 'e',
+        'prepend': 'a',
+        'remove': 'c',
+    })
+    def test_method_list_override_strings(self):
+        self.assertListEqual(settings.ITEMS, ['a', 'b', 'd', 'e'])
+
+    @modify_settings(ITEMS={'remove': ['b', 'd']})
+    @modify_settings(ITEMS={'append': ['b'], 'prepend': ['d']})
+    def test_method_list_override_nested_order(self):
+        self.assertListEqual(settings.ITEMS, ['d', 'c', 'b'])
+
     @override_settings(TEST='override2')
     def test_method_override(self):
         self.assertEqual(settings.TEST, 'override2')
@@ -31,14 +69,26 @@ class FullyDecoratedTranTestCase(TransactionTestCase):
         self.assertEqual(FullyDecoratedTranTestCase.__module__, __name__)
 
 
-@override_settings(TEST='override')
+@modify_settings(ITEMS={
+    'prepend': ['b'],
+    'append': ['d'],
+    'remove': ['a', 'e']
+})
+@override_settings(ITEMS=['a', 'c', 'e'], TEST='override')
 class FullyDecoratedTestCase(TestCase):
 
     def test_override(self):
+        self.assertListEqual(settings.ITEMS, ['b', 'c', 'd'])
         self.assertEqual(settings.TEST, 'override')
 
+    @modify_settings(ITEMS={
+        'append': 'e',
+        'prepend': 'a',
+        'remove': 'c',
+    })
     @override_settings(TEST='override2')
     def test_method_override(self):
+        self.assertListEqual(settings.ITEMS, ['a', 'b', 'd', 'e'])
         self.assertEqual(settings.TEST, 'override2')
 
 
@@ -73,14 +123,17 @@ class ClassDecoratedTestCase(ClassDecoratedTestCaseSuper):
             self.fail()
 
 
-@override_settings(TEST='override-parent')
+@modify_settings(ITEMS={'append': 'mother'})
+@override_settings(ITEMS=['father'], TEST='override-parent')
 class ParentDecoratedTestCase(TestCase):
     pass
 
 
+@modify_settings(ITEMS={'append': ['child']})
 @override_settings(TEST='override-child')
 class ChildDecoratedTestCase(ParentDecoratedTestCase):
     def test_override_settings_inheritance(self):
+        self.assertEqual(settings.ITEMS, ['father', 'mother', 'child'])
         self.assertEqual(settings.TEST, 'override-child')
 
 

+ 1 - 0
tests/view_tests/tests/test_i18n.py

@@ -135,6 +135,7 @@ class JsI18NTests(TestCase):
         response = self.client.get('/views/jsi18n_admin/?language=de')
         self.assertContains(response, '\\x04')
 
+
 class JsI18NTestsMultiPackage(TestCase):
     """
     Tests for django views in django/views/i18n.py that need to change