Skip to content

Commit

Permalink
Adds tests for invalid template variables
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
codingjoe committed Mar 31, 2015
1 parent 1e72766 commit 194ac51
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 1 deletion.
19 changes: 18 additions & 1 deletion docs/helpers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------

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


``rf`` - ``RequestFactory``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

Expand Down
7 changes: 7 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ the command line::
See the `py.test documentation on Usage and invocations
<http://pytest.org/latest/usage.html>`_ 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
Expand Down
99 changes: 99 additions & 0 deletions pytest_django/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"""

import contextlib
import inspect
from functools import reduce
import os
import sys
import types
Expand All @@ -27,6 +29,7 @@

SETTINGS_MODULE_ENV = 'DJANGO_SETTINGS_MODULE'
CONFIGURATION_ENV = 'DJANGO_CONFIGURATION'
INVALID_TEMPLATE_VARS_ENV = 'FAIL_INVALID_TEMPLATE_VARS'


# ############### pytest hooks ################
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ################


Expand Down
99 changes: 99 additions & 0 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<div>{{ invalid_var }}</div>",
'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(
"<div>{{ invalid_var }}</div>",
'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
Expand Down

0 comments on commit 194ac51

Please sign in to comment.