From 194ac51fdbc8b02942d93da19071ce20b8f769f0 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sun, 22 Mar 2015 19:39:38 +0100 Subject: [PATCH] Adds tests for invalid template variables Django catches all `VariableDoesNotExist` exceptions to replace them in templates with a modifiable string that you can define in your settings. Sadly that doesn't allow you to find them in unit tests. `_fail_for_invalid_template_variable` sets the setting `TEMPLATE_STRING_IF_INVALID` to a custom class that not only fails the current test but prints a pretty message including the template name. This behavior can be used with the new `--test-templates` command line option. A new marker allows disabling this behavior, eg: @pytest.mark.ignore_template_errors def test_something(): pass This marker sets the setting to None, if you want it to be a string, you can use the `settings` fixture to set it to your desired value. --- docs/helpers.rst | 19 +++++++- docs/usage.rst | 7 +++ pytest_django/plugin.py | 99 +++++++++++++++++++++++++++++++++++++++ tests/test_environment.py | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) diff --git a/docs/helpers.rst b/docs/helpers.rst index 573f17010..7c60f9005 100644 --- a/docs/helpers.rst +++ b/docs/helpers.rst @@ -77,6 +77,23 @@ on what marks are and for notes on using_ them. assert 'Success!' in client.get('/some_url_defined_in_test_urls/') +``pytest.mark.ignore_template_errors`` - ignore invalid template variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +..py:function:: pytest.mark.ignore_template_errors + + If you run py.test using the ``--fail-on-template-vars`` option, + tests will fail should your templates contain any invalid variables. + This marker will disable this feature by setting ``settings.TEMPLATE_STRING_IF_INVALID=None`` + or the ``string_if_invalid`` template option in Django>=1.7 + + Example usage:: + + @pytest.mark.ignore_template_errors + def test_something(client): + client('some-url-with-invalid-template-vars') + + Fixtures -------- @@ -86,7 +103,7 @@ More information on fixtures is available in the `py.test documentation ``rf`` - ``RequestFactory`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~ An instance of a `django.test.RequestFactory`_ diff --git a/docs/usage.rst b/docs/usage.rst index 8a3f94e59..2bcc58720 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -22,6 +22,13 @@ the command line:: See the `py.test documentation on Usage and invocations `_ for more help on available parameters. +Additional command line options +------------------------------- + +``--fail-on-template-vars`` - fail for invalid variables in templates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Fail tests that render templates which make use of invalid template variables. + Running tests in parallel with pytest-xdist ------------------------------------------- pytest-django supports running tests on multiple processes to speed up test diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 31a967aac..4ef883193 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -5,6 +5,8 @@ """ import contextlib +import inspect +from functools import reduce import os import sys import types @@ -27,6 +29,7 @@ SETTINGS_MODULE_ENV = 'DJANGO_SETTINGS_MODULE' CONFIGURATION_ENV = 'DJANGO_CONFIGURATION' +INVALID_TEMPLATE_VARS_ENV = 'FAIL_INVALID_TEMPLATE_VARS' # ############### pytest hooks ################ @@ -62,6 +65,12 @@ def pytest_addoption(parser): 'Automatically find and add a Django project to the ' 'Python path.', default=True) + group._addoption('--fail-on-template-vars', + action='store_true', dest='itv', default=False, + help='Fail for invalid variables in templates.') + parser.addini(INVALID_TEMPLATE_VARS_ENV, + 'Fail for invalid variables in templates.', + default=False) def _exists(path, ignore=EnvironmentError): @@ -170,6 +179,14 @@ def pytest_load_initial_conftests(early_config, parser, args): else: _django_project_scan_outcome = PROJECT_SCAN_DISABLED + # Configure FAIL_INVALID_TEMPLATE_VARS + itv = (options.itv or + early_config.getini(INVALID_TEMPLATE_VARS_ENV) or + os.environ.get(INVALID_TEMPLATE_VARS_ENV)) + + if itv: + os.environ[INVALID_TEMPLATE_VARS_ENV] = 'True' if itv else None + # Configure DJANGO_SETTINGS_MODULE ds = (options.ds or early_config.getini(SETTINGS_MODULE_ENV) or @@ -327,6 +344,88 @@ def restore(): request.addfinalizer(restore) +@pytest.fixture(autouse=True, scope='session') +def _fail_for_invalid_template_variable(request): + """Fixture that fails for invalid variables in templates. + + This fixture will fail each test that uses django template rendering + should a template contain an invalid template variable. + The fail message will include the name of the invalid variable and + in most cases the template name. + + It does not raise an exception, but fails, as the stack trace doesn't + offer any helpful information to debug. + This behavior can be switched of using the marker: + ``ignore_template_errors`` + """ + class InvalidVarException(object): + """Custom handler for invalid strings in templates.""" + + def __init__(self): + self.fail = True + + def __contains__(self, key): + """There is a test for '%s' in TEMPLATE_STRING_IF_INVALID.""" + return key == '%s' + + def _get_template(self): + from django.template import Template + + stack = inspect.stack() + # finding the ``render`` needle in the stack + frame = reduce( + lambda x, y: y[3] == 'render' and 'base.py' in y[1] and y or x, + stack + ) + # assert 0, stack + frame = frame[0] + # finding only the frame locals in all frame members + f_locals = reduce( + lambda x, y: y[0] == 'f_locals' and y or x, + inspect.getmembers(frame) + )[1] + # ``django.template.base.Template`` + template = f_locals['self'] + if isinstance(template, Template): + return template + + def __mod__(self, var): + """Handle TEMPLATE_STRING_IF_INVALID % var.""" + template = self._get_template() + if template: + msg = "Undefined template variable '%s' in '%s'" % (var, template.name) + else: + msg = "Undefined template variable '%s'" % var + if self.fail: + pytest.fail(msg, pytrace=False) + else: + return msg + if os.environ.get(INVALID_TEMPLATE_VARS_ENV, False): + if django_settings_is_configured(): + import django + from django.conf import settings + + if django.VERSION >= (1, 8) and settings.TEMPLATES: + settings.TEMPLATES[0]['OPTIONS']['string_if_invalid'] = InvalidVarException() + else: + settings.TEMPLATE_STRING_IF_INVALID = InvalidVarException() + + +@pytest.fixture(autouse=True) +def _template_string_if_invalid_marker(request): + """Apply the @pytest.mark.ignore_template_errors marker, + internal to pytest-django.""" + marker = request.keywords.get('ignore_template_errors', None) + if os.environ.get(INVALID_TEMPLATE_VARS_ENV, False): + if marker and django_settings_is_configured(): + import django + from django.conf import settings + + if django.VERSION >= (1, 8) and settings.TEMPLATES: + settings.TEMPLATES[0]['OPTIONS']['string_if_invalid'].fail = False + else: + settings.TEMPLATE_STRING_IF_INVALID.fail = False + # ############### Helper Functions ################ diff --git a/tests/test_environment.py b/tests/test_environment.py index b7c40ce7b..2a191118b 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -28,6 +28,105 @@ def test_mail_again(): test_mail() +@pytest.mark.django_project(extra_settings=""" + INSTALLED_APPS = [ + 'tpkg.app', + ] + TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ) + ROOT_URLCONF = 'tpkg.app.urls' + """) +def test_invalid_template_variable(django_testdir): + django_testdir.create_app_file(""" + try: + from django.conf.urls import patterns # Django >1.4 + except ImportError: + from django.conf.urls.defaults import patterns # Django 1.3 + + urlpatterns = patterns( + '', + (r'invalid_template/', 'tpkg.app.views.invalid_template'), + ) + """, 'urls.py') + django_testdir.create_app_file(""" + from django.shortcuts import render + + + def invalid_template(request): + return render(request, 'invalid_template.html', {}) + """, 'views.py') + django_testdir.create_app_file( + "
{{ invalid_var }}
", + 'templates/invalid_template.html' + ) + django_testdir.create_test_module(''' + import pytest + + def test_for_invalid_template(client): + client.get('/invalid_template/') + + @pytest.mark.ignore_template_errors + def test_ignore(client): + client.get('/invalid_template/') + ''') + result = django_testdir.runpytest('-s', '--fail-on-template-vars') + result.stdout.fnmatch_lines_random([ + "tpkg/test_the_test.py F.", + "Undefined template variable 'invalid_var' in 'invalid_template.html'", + ]) + + +@pytest.mark.django_project(extra_settings=""" + INSTALLED_APPS = [ + 'tpkg.app', + ] + TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ) + ROOT_URLCONF = 'tpkg.app.urls' + """) +def test_invalid_template_variable_opt_in(django_testdir): + django_testdir.create_app_file(""" + try: + from django.conf.urls import patterns # Django >1.4 + except ImportError: + from django.conf.urls.defaults import patterns # Django 1.3 + + urlpatterns = patterns( + '', + (r'invalid_template/', 'tpkg.app.views.invalid_template'), + ) + """, 'urls.py') + django_testdir.create_app_file(""" + from django.shortcuts import render + + + def invalid_template(request): + return render(request, 'invalid_template.html', {}) + """, 'views.py') + django_testdir.create_app_file( + "
{{ invalid_var }}
", + 'templates/invalid_template.html' + ) + django_testdir.create_test_module(''' + import pytest + + def test_for_invalid_template(client): + client.get('/invalid_template/') + + @pytest.mark.ignore_template_errors + def test_ignore(client): + client.get('/invalid_template/') + ''') + result = django_testdir.runpytest('-s') + result.stdout.fnmatch_lines_random([ + "tpkg/test_the_test.py ..", + ]) + + @pytest.mark.django_db def test_database_rollback(): assert Item.objects.count() == 0