Skip to content

Commit 954081f

Browse files
committed
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.
1 parent b48c02d commit 954081f

File tree

4 files changed

+223
-1
lines changed

4 files changed

+223
-1
lines changed

docs/helpers.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@ on what marks are and for notes on using_ them.
7777
assert 'Success!' in client.get('/some_url_defined_in_test_urls/')
7878

7979

80+
``pytest.mark.ignore_template_errors`` - ignore invalid template variables
81+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
82+
83+
..py:function:: pytest.mark.ignore_template_errors
84+
85+
If you run py.test using the ``--fail-on-template-vars`` option,
86+
tests will fail should your templates contain any invalid variables.
87+
This marker will disable this feature by setting ``settings.TEMPLATE_STRING_IF_INVALID=None``
88+
or the ``string_if_invalid`` template option in Django>=1.7
89+
90+
Example usage::
91+
92+
@pytest.mark.ignore_template_errors
93+
def test_something(client):
94+
client('some-url-with-invalid-template-vars')
95+
96+
8097
Fixtures
8198
--------
8299

@@ -86,7 +103,7 @@ More information on fixtures is available in the `py.test documentation
86103

87104

88105
``rf`` - ``RequestFactory``
89-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
106+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
90107

91108
An instance of a `django.test.RequestFactory`_
92109

docs/usage.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ the command line::
2222
See the `py.test documentation on Usage and invocations
2323
<http://pytest.org/latest/usage.html>`_ for more help on available parameters.
2424

25+
Additional command line options
26+
-------------------------------
27+
28+
``--fail-on-template-vars`` - fail for invalid variables in templates
29+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
30+
Fail tests that render templates which make use of invalid template variables.
31+
2532
Running tests in parallel with pytest-xdist
2633
-------------------------------------------
2734
pytest-django supports running tests on multiple processes to speed up test

pytest_django/plugin.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"""
66

77
import contextlib
8+
import inspect
9+
from functools import reduce
810
import os
911
import sys
1012
import types
@@ -27,6 +29,7 @@
2729

2830
SETTINGS_MODULE_ENV = 'DJANGO_SETTINGS_MODULE'
2931
CONFIGURATION_ENV = 'DJANGO_CONFIGURATION'
32+
INVALID_TEMPLATE_VARS_ENV = 'FAIL_INVALID_TEMPLATE_VARS'
3033

3134

3235
# ############### pytest hooks ################
@@ -62,6 +65,12 @@ def pytest_addoption(parser):
6265
'Automatically find and add a Django project to the '
6366
'Python path.',
6467
default=True)
68+
group._addoption('--fail-on-template-vars',
69+
action='store_true', dest='itv', default=False,
70+
help='Fail for invalid variables in templates.')
71+
parser.addini(INVALID_TEMPLATE_VARS_ENV,
72+
'Fail for invalid variables in templates.',
73+
default=False)
6574

6675

6776
def _exists(path, ignore=EnvironmentError):
@@ -170,6 +179,14 @@ def pytest_load_initial_conftests(early_config, parser, args):
170179
else:
171180
_django_project_scan_outcome = PROJECT_SCAN_DISABLED
172181

182+
# Configure FAIL_INVALID_TEMPLATE_VARS
183+
itv = (options.itv or
184+
os.environ.get(INVALID_TEMPLATE_VARS_ENV) in ['true', 'True', '1'] or
185+
early_config.getini(INVALID_TEMPLATE_VARS_ENV))
186+
187+
if itv:
188+
os.environ[INVALID_TEMPLATE_VARS_ENV] = 'true'
189+
173190
# Configure DJANGO_SETTINGS_MODULE
174191
ds = (options.ds or
175192
os.environ.get(SETTINGS_MODULE_ENV) or
@@ -327,6 +344,88 @@ def restore():
327344
request.addfinalizer(restore)
328345

329346

347+
@pytest.fixture(autouse=True, scope='session')
348+
def _fail_for_invalid_template_variable(request):
349+
"""Fixture that fails for invalid variables in templates.
350+
351+
This fixture will fail each test that uses django template rendering
352+
should a template contain an invalid template variable.
353+
The fail message will include the name of the invalid variable and
354+
in most cases the template name.
355+
356+
It does not raise an exception, but fails, as the stack trace doesn't
357+
offer any helpful information to debug.
358+
This behavior can be switched off using the marker:
359+
``ignore_template_errors``
360+
"""
361+
class InvalidVarException(object):
362+
"""Custom handler for invalid strings in templates."""
363+
364+
def __init__(self):
365+
self.fail = True
366+
367+
def __contains__(self, key):
368+
"""There is a test for '%s' in TEMPLATE_STRING_IF_INVALID."""
369+
return key == '%s'
370+
371+
def _get_template(self):
372+
from django.template import Template
373+
374+
stack = inspect.stack()
375+
# finding the ``render`` needle in the stack
376+
frame = reduce(
377+
lambda x, y: y[3] == 'render' and 'base.py' in y[1] and y or x,
378+
stack
379+
)
380+
# assert 0, stack
381+
frame = frame[0]
382+
# finding only the frame locals in all frame members
383+
f_locals = reduce(
384+
lambda x, y: y[0] == 'f_locals' and y or x,
385+
inspect.getmembers(frame)
386+
)[1]
387+
# ``django.template.base.Template``
388+
template = f_locals['self']
389+
if isinstance(template, Template):
390+
return template
391+
392+
def __mod__(self, var):
393+
"""Handle TEMPLATE_STRING_IF_INVALID % var."""
394+
template = self._get_template()
395+
if template:
396+
msg = "Undefined template variable '%s' in '%s'" % (var, template.name)
397+
else:
398+
msg = "Undefined template variable '%s'" % var
399+
if self.fail:
400+
pytest.fail(msg, pytrace=False)
401+
else:
402+
return msg
403+
if os.environ.get(INVALID_TEMPLATE_VARS_ENV, 'false') == 'true':
404+
if django_settings_is_configured():
405+
import django
406+
from django.conf import settings
407+
408+
if django.VERSION >= (1, 8) and settings.TEMPLATES:
409+
settings.TEMPLATES[0]['OPTIONS']['string_if_invalid'] = InvalidVarException()
410+
else:
411+
settings.TEMPLATE_STRING_IF_INVALID = InvalidVarException()
412+
413+
414+
@pytest.fixture(autouse=True)
415+
def _template_string_if_invalid_marker(request):
416+
"""Apply the @pytest.mark.ignore_template_errors marker,
417+
internal to pytest-django."""
418+
marker = request.keywords.get('ignore_template_errors', None)
419+
if os.environ.get(INVALID_TEMPLATE_VARS_ENV, 'false') == 'true':
420+
if marker and django_settings_is_configured():
421+
import django
422+
from django.conf import settings
423+
424+
if django.VERSION >= (1, 8) and settings.TEMPLATES:
425+
settings.TEMPLATES[0]['OPTIONS']['string_if_invalid'].fail = False
426+
else:
427+
settings.TEMPLATE_STRING_IF_INVALID.fail = False
428+
330429
# ############### Helper Functions ################
331430

332431

tests/test_environment.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,105 @@ def test_mail_again():
2828
test_mail()
2929

3030

31+
@pytest.mark.django_project(extra_settings="""
32+
INSTALLED_APPS = [
33+
'tpkg.app',
34+
]
35+
TEMPLATE_LOADERS = (
36+
'django.template.loaders.filesystem.Loader',
37+
'django.template.loaders.app_directories.Loader',
38+
)
39+
ROOT_URLCONF = 'tpkg.app.urls'
40+
""")
41+
def test_invalid_template_variable(django_testdir):
42+
django_testdir.create_app_file("""
43+
try:
44+
from django.conf.urls import patterns # Django >1.4
45+
except ImportError:
46+
from django.conf.urls.defaults import patterns # Django 1.3
47+
48+
urlpatterns = patterns(
49+
'',
50+
(r'invalid_template/', 'tpkg.app.views.invalid_template'),
51+
)
52+
""", 'urls.py')
53+
django_testdir.create_app_file("""
54+
from django.shortcuts import render
55+
56+
57+
def invalid_template(request):
58+
return render(request, 'invalid_template.html', {})
59+
""", 'views.py')
60+
django_testdir.create_app_file(
61+
"<div>{{ invalid_var }}</div>",
62+
'templates/invalid_template.html'
63+
)
64+
django_testdir.create_test_module('''
65+
import pytest
66+
67+
def test_for_invalid_template(client):
68+
client.get('/invalid_template/')
69+
70+
@pytest.mark.ignore_template_errors
71+
def test_ignore(client):
72+
client.get('/invalid_template/')
73+
''')
74+
result = django_testdir.runpytest('-s', '--fail-on-template-vars')
75+
result.stdout.fnmatch_lines_random([
76+
"tpkg/test_the_test.py F.",
77+
"Undefined template variable 'invalid_var' in 'invalid_template.html'",
78+
])
79+
80+
81+
@pytest.mark.django_project(extra_settings="""
82+
INSTALLED_APPS = [
83+
'tpkg.app',
84+
]
85+
TEMPLATE_LOADERS = (
86+
'django.template.loaders.filesystem.Loader',
87+
'django.template.loaders.app_directories.Loader',
88+
)
89+
ROOT_URLCONF = 'tpkg.app.urls'
90+
""")
91+
def test_invalid_template_variable_opt_in(django_testdir):
92+
django_testdir.create_app_file("""
93+
try:
94+
from django.conf.urls import patterns # Django >1.4
95+
except ImportError:
96+
from django.conf.urls.defaults import patterns # Django 1.3
97+
98+
urlpatterns = patterns(
99+
'',
100+
(r'invalid_template/', 'tpkg.app.views.invalid_template'),
101+
)
102+
""", 'urls.py')
103+
django_testdir.create_app_file("""
104+
from django.shortcuts import render
105+
106+
107+
def invalid_template(request):
108+
return render(request, 'invalid_template.html', {})
109+
""", 'views.py')
110+
django_testdir.create_app_file(
111+
"<div>{{ invalid_var }}</div>",
112+
'templates/invalid_template.html'
113+
)
114+
django_testdir.create_test_module('''
115+
import pytest
116+
117+
def test_for_invalid_template(client):
118+
client.get('/invalid_template/')
119+
120+
@pytest.mark.ignore_template_errors
121+
def test_ignore(client):
122+
client.get('/invalid_template/')
123+
''')
124+
result = django_testdir.runpytest('-s')
125+
result.stdout.fnmatch_lines_random([
126+
"tpkg/test_the_test.py ..",
127+
])
128+
129+
31130
@pytest.mark.django_db
32131
def test_database_rollback():
33132
assert Item.objects.count() == 0

0 commit comments

Comments
 (0)