Skip to content

Commit cc7cc10

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 1e72766 commit cc7cc10

File tree

4 files changed

+222
-1
lines changed

4 files changed

+222
-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: 98 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,13 @@ 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) or
185+
early_config.getini(INVALID_TEMPLATE_VARS_ENV))
186+
187+
os.environ[INVALID_TEMPLATE_VARS_ENV] = 'True' if itv else None
188+
173189
# Configure DJANGO_SETTINGS_MODULE
174190
ds = (options.ds or
175191
early_config.getini(SETTINGS_MODULE_ENV) or
@@ -327,6 +343,88 @@ def restore():
327343
request.addfinalizer(restore)
328344

329345

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

332430

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)