From 5241763c81b6afe1c0327ff7eb0d75c643f24ce0 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Mon, 23 Dec 2013 12:39:19 +0100 Subject: [PATCH] Added modify_settings to alter settings containing lists of values. --- django/test/__init__.py | 4 +- django/test/testcases.py | 27 +++++-- django/test/utils.py | 75 ++++++++++++++++--- docs/topics/testing/overview.txt | 110 ++++++++++++++++++++++------ tests/settings_tests/tests.py | 61 ++++++++++++++- tests/view_tests/tests/test_i18n.py | 1 + 6 files changed, 229 insertions(+), 49 deletions(-) diff --git a/django/test/__init__.py b/django/test/__init__.py index 4b17ed18c9..c611ef6850 100644 --- a/django/test/__init__.py +++ b/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', ] diff --git a/django/test/testcases.py b/django/test/testcases.py index 1532ec433f..0e1bb78d46 100644 --- a/django/test/testcases.py +++ b/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): diff --git a/django/test/utils.py b/django/test/utils.py index ce7536bd6d..60e125a86b 100644 --- a/django/test/utils.py +++ b/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 diff --git a/docs/topics/testing/overview.txt b/docs/topics/testing/overview.txt index f8464638c2..3bdb13c28f 100644 --- a/docs/topics/testing/overview.txt +++ b/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: diff --git a/tests/settings_tests/tests.py b/tests/settings_tests/tests.py index 6fc08ebbe5..fd3f543553 100644 --- a/tests/settings_tests/tests.py +++ b/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') diff --git a/tests/view_tests/tests/test_i18n.py b/tests/view_tests/tests/test_i18n.py index f3d676fc0f..8f0f9896ba 100644 --- a/tests/view_tests/tests/test_i18n.py +++ b/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