Browse Source

Fixed #24119, #24120 -- Formalized debug integration for template backends.

Preston Timmons 10 years ago
parent
commit
adff499e47

+ 24 - 6
django/template/backends/django.py

@@ -1,11 +1,14 @@
 # Since this package contains a "django" module, this is required on Python 2.
 from __future__ import absolute_import
 
+import sys
 import warnings
 
 from django.conf import settings
+from django.template import TemplateDoesNotExist
 from django.template.context import Context, RequestContext, make_context
 from django.template.engine import Engine, _dirs_undefined
+from django.utils import six
 from django.utils.deprecation import RemovedInDjango20Warning
 
 from .base import BaseEngine
@@ -24,21 +27,23 @@ class DjangoTemplates(BaseEngine):
         self.engine = Engine(self.dirs, self.app_dirs, **options)
 
     def from_string(self, template_code):
-        return Template(self.engine.from_string(template_code))
+        return Template(self.engine.from_string(template_code), self)
 
     def get_template(self, template_name, dirs=_dirs_undefined):
-        return Template(self.engine.get_template(template_name, dirs))
+        try:
+            return Template(self.engine.get_template(template_name, dirs), self)
+        except TemplateDoesNotExist as exc:
+            reraise(exc, self)
 
 
 class Template(object):
 
-    def __init__(self, template):
+    def __init__(self, template, backend):
         self.template = template
+        self.backend = backend
 
     @property
     def origin(self):
-        # TODO: define the Origin API. For now simply forwarding to the
-        #       underlying Template preserves backwards-compatibility.
         return self.template.origin
 
     def render(self, context=None, request=None):
@@ -71,4 +76,17 @@ class Template(object):
         else:
             context = make_context(context, request)
 
-        return self.template.render(context)
+        try:
+            return self.template.render(context)
+        except TemplateDoesNotExist as exc:
+            reraise(exc, self.backend)
+
+
+def reraise(exc, backend):
+    """
+    Reraise TemplateDoesNotExist while maintaining template debug information.
+    """
+    new = exc.__class__(*exc.args, tried=exc.tried, backend=backend)
+    if hasattr(exc, 'template_debug'):
+        new.template_debug = exc.template_debug
+    six.reraise(exc.__class__, new, sys.exc_info()[2])

+ 12 - 4
django/template/backends/dummy.py

@@ -1,12 +1,13 @@
 # Since this package contains a "django" module, this is required on Python 2.
 from __future__ import absolute_import
 
+import errno
 import io
 import string
 
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
-from django.template import TemplateDoesNotExist
+from django.template import Origin, TemplateDoesNotExist
 from django.utils.html import conditional_escape
 
 from .base import BaseEngine
@@ -29,17 +30,24 @@ class TemplateStrings(BaseEngine):
         return Template(template_code)
 
     def get_template(self, template_name):
+        tried = []
         for template_file in self.iter_template_filenames(template_name):
             try:
                 with io.open(template_file, encoding=settings.FILE_CHARSET) as fp:
                     template_code = fp.read()
-            except IOError:
-                continue
+            except IOError as e:
+                if e.errno == errno.ENOENT:
+                    tried.append((
+                        Origin(template_file, template_name, self),
+                        'Source does not exist',
+                    ))
+                    continue
+                raise
 
             return Template(template_code)
 
         else:
-            raise TemplateDoesNotExist(template_name)
+            raise TemplateDoesNotExist(template_name, tried=tried, backend=self)
 
 
 class Template(string.Template):

+ 48 - 4
django/template/backends/jinja2.py

@@ -41,17 +41,24 @@ class Jinja2(BaseEngine):
         try:
             return Template(self.env.get_template(template_name))
         except jinja2.TemplateNotFound as exc:
-            six.reraise(TemplateDoesNotExist, TemplateDoesNotExist(exc.args),
-                        sys.exc_info()[2])
+            six.reraise(
+                TemplateDoesNotExist,
+                TemplateDoesNotExist(exc.name, backend=self),
+                sys.exc_info()[2],
+            )
         except jinja2.TemplateSyntaxError as exc:
-            six.reraise(TemplateSyntaxError, TemplateSyntaxError(exc.args),
-                        sys.exc_info()[2])
+            new = TemplateSyntaxError(exc.args)
+            new.template_debug = get_exception_info(exc)
+            six.reraise(TemplateSyntaxError, new, sys.exc_info()[2])
 
 
 class Template(object):
 
     def __init__(self, template):
         self.template = template
+        self.origin = Origin(
+            name=template.filename, template_name=template.name,
+        )
 
     def render(self, context=None, request=None):
         if context is None:
@@ -61,3 +68,40 @@ class Template(object):
             context['csrf_input'] = csrf_input_lazy(request)
             context['csrf_token'] = csrf_token_lazy(request)
         return self.template.render(context)
+
+
+class Origin(object):
+    """
+    A container to hold debug information as described in the template API
+    documentation.
+    """
+    def __init__(self, name, template_name):
+        self.name = name
+        self.template_name = template_name
+
+
+def get_exception_info(exception):
+    """
+    Formats exception information for display on the debug page using the
+    structure described in the template API documentation.
+    """
+    context_lines = 10
+    lineno = exception.lineno
+    lines = list(enumerate(exception.source.strip().split("\n"), start=1))
+    during = lines[lineno - 1][1]
+    total = len(lines)
+    top = max(0, lineno - context_lines - 1)
+    bottom = min(total, lineno + context_lines)
+
+    return {
+        'name': exception.filename,
+        'message': exception.message,
+        'source_lines': lines[top:bottom],
+        'line': lineno,
+        'before': '',
+        'during': during,
+        'after': '',
+        'total': total,
+        'top': top,
+        'bottom': bottom,
+    }

+ 19 - 5
django/template/base.py

@@ -135,13 +135,27 @@ class TemplateSyntaxError(Exception):
 
 class TemplateDoesNotExist(Exception):
     """
-    This exception is used when template loaders are unable to find a
-    template. The tried argument is an optional list of tuples containing
-    (origin, status), where origin is an Origin object and status is a string
-    with the reason the template wasn't found.
+    The exception used by backends when a template does not exist. Accepts the
+    following optional arguments:
+
+    backend
+        The template backend class used when raising this exception.
+
+    tried
+        A list of sources that were tried when finding the template. This
+        is formatted as a list of tuples containing (origin, status), where
+        origin is an Origin object and status is a string with the reason the
+        template wasn't found.
+
+    chain
+        A list of intermediate TemplateDoesNotExist exceptions. This is used to
+        encapsulate multiple exceptions when loading templates from multiple
+        engines.
     """
-    def __init__(self, msg, tried=None):
+    def __init__(self, msg, tried=None, backend=None, chain=None):
+        self.backend = backend
         self.tried = tried or []
+        self.chain = chain or []
         super(TemplateDoesNotExist, self).__init__(msg)
 
 

+ 9 - 9
django/template/loader.py

@@ -17,7 +17,7 @@ def get_template(template_name, dirs=_dirs_undefined, using=None):
 
     Raises TemplateDoesNotExist if no such template exists.
     """
-    tried = []
+    chain = []
     engines = _engine_list(using)
     for engine in engines:
         try:
@@ -33,9 +33,9 @@ def get_template(template_name, dirs=_dirs_undefined, using=None):
             else:
                 return engine.get_template(template_name)
         except TemplateDoesNotExist as e:
-            tried.extend(e.tried)
+            chain.append(e)
 
-    raise TemplateDoesNotExist(template_name, tried=tried)
+    raise TemplateDoesNotExist(template_name, chain=chain)
 
 
 def select_template(template_name_list, dirs=_dirs_undefined, using=None):
@@ -46,7 +46,7 @@ def select_template(template_name_list, dirs=_dirs_undefined, using=None):
 
     Raises TemplateDoesNotExist if no such template exists.
     """
-    tried = []
+    chain = []
     engines = _engine_list(using)
     for template_name in template_name_list:
         for engine in engines:
@@ -63,10 +63,10 @@ def select_template(template_name_list, dirs=_dirs_undefined, using=None):
                 else:
                     return engine.get_template(template_name)
             except TemplateDoesNotExist as e:
-                tried.extend(e.tried)
+                chain.append(e)
 
     if template_name_list:
-        raise TemplateDoesNotExist(', '.join(template_name_list), tried=tried)
+        raise TemplateDoesNotExist(', '.join(template_name_list), chain=chain)
     else:
         raise TemplateDoesNotExist("No template names provided")
 
@@ -92,7 +92,7 @@ def render_to_string(template_name, context=None,
         return template.render(context, request)
 
     else:
-        tried = []
+        chain = []
         # Some deprecated arguments were passed - use the legacy code path
         for engine in _engine_list(using):
             try:
@@ -124,13 +124,13 @@ def render_to_string(template_name, context=None,
                         "method doesn't support the dictionary argument." %
                         engine.name, stacklevel=2)
             except TemplateDoesNotExist as e:
-                tried.extend(e.tried)
+                chain.append(e)
                 continue
 
         if template_name:
             if isinstance(template_name, (list, tuple)):
                 template_name = ', '.join(template_name)
-            raise TemplateDoesNotExist(template_name, tried=tried)
+            raise TemplateDoesNotExist(template_name, chain=chain)
         else:
             raise TemplateDoesNotExist("No template names provided")
 

+ 5 - 23
django/views/debug.py

@@ -9,7 +9,7 @@ from django.core.urlresolvers import Resolver404, resolve
 from django.http import (
     HttpRequest, HttpResponse, HttpResponseNotFound, build_request_repr,
 )
-from django.template import Context, Engine, TemplateDoesNotExist, engines
+from django.template import Context, Engine, TemplateDoesNotExist
 from django.template.defaultfilters import force_escape, pprint
 from django.utils import lru_cache, six, timezone
 from django.utils.datastructures import MultiValueDict
@@ -276,25 +276,7 @@ class ExceptionReporter(object):
         """Return a dictionary containing traceback information."""
         if self.exc_type and issubclass(self.exc_type, TemplateDoesNotExist):
             self.template_does_not_exist = True
-            postmortem = []
-
-            # TODO: add support for multiple template engines (#24120).
-            # TemplateDoesNotExist should carry all the information, including
-            # the backend, rather than looping through engines.all.
-            for engine in engines.all():
-                if hasattr(engine, 'engine'):
-                    e = engine.engine
-                else:
-                    e = engine
-
-                postmortem.append(dict(
-                    engine=engine,
-                    tried=[
-                        entry for entry in self.exc_value.tried if
-                        entry[0].loader.engine == e
-                    ],
-                ))
-            self.postmortem = postmortem
+            self.postmortem = self.exc_value.chain or [self.exc_value]
 
         frames = self.get_traceback_frames()
         for i, frame in enumerate(frames):
@@ -751,7 +733,7 @@ TECHNICAL_500_TEMPLATE = ("""
     {% if postmortem %}
         <p class="append-bottom">Django tried loading these templates, in this order:</p>
         {% for entry in postmortem %}
-            <p class="postmortem-section">Using engine <code>{{ entry.engine.name }}</code>:</p>
+            <p class="postmortem-section">Using engine <code>{{ entry.backend.name }}</code>:</p>
             <ul>
                 {% if entry.tried %}
                     {% for attempt in entry.tried %}
@@ -890,7 +872,7 @@ Installed Middleware:
 {% if template_does_not_exist %}Template loader postmortem
 {% if postmortem %}Django tried loading these templates, in this order:
 {% for entry in postmortem %}
-Using engine {{ entry.engine.name }}:
+Using engine {{ entry.backend.name }}:
 {% if entry.tried %}{% for attempt in entry.tried %}    * {{ attempt.0.loader_name }}: {{ attempt.0.name }} ({{ attempt.1 }})
 {% endfor %}{% else %}    This engine did not provide a list of tried templates.
 {% endif %}{% endfor %}
@@ -1083,7 +1065,7 @@ Installed Middleware:
 {% if template_does_not_exist %}Template loader postmortem
 {% if postmortem %}Django tried loading these templates, in this order:
 {% for entry in postmortem %}
-Using engine {{ entry.engine.name }}:
+Using engine {{ entry.backend.name }}:
 {% if entry.tried %}{% for attempt in entry.tried %}    * {{ attempt.0.loader_name }}: {{ attempt.0.name }} ({{ attempt.1 }})
 {% endfor %}{% else %}    This engine did not provide a list of tried templates.
 {% endif %}{% endfor %}

+ 3 - 0
docs/releases/1.9.txt

@@ -249,6 +249,9 @@ Templates
 * The debug page template postmortem now include output from each engine that
   is installed.
 
+* :ref:`Debug page integration <template-debug-integration>` for custom
+  template engines was added.
+
 Requests and Responses
 ^^^^^^^^^^^^^^^^^^^^^^
 

BIN
docs/topics/_images/postmortem.png


BIN
docs/topics/_images/template-lines.png


+ 137 - 5
docs/topics/templates.txt

@@ -152,11 +152,32 @@ The ``django.template.loader`` module defines two functions to load templates.
 If loading a template fails, the following two exceptions, defined in
 ``django.template``, may be raised:
 
-.. exception:: TemplateDoesNotExist
+.. exception:: TemplateDoesNotExist(msg, tried=None, backend=None, chain=None)
 
-    This exception is raised when a template cannot be found.
+    This exception is raised when a template cannot be found. It accepts the
+    following optional arguments for populating the :ref:`template postmortem
+    <template-postmortem>` on the debug page:
 
-.. exception:: TemplateSyntaxError
+    ``backend``
+        The template backend instance from which the exception originated.
+
+    ``tried``
+        A list of sources that were tried when finding the template. This is
+        formatted as a list of tuples containing ``(origin, status)``, where
+        ``origin`` is an :ref:`origin-like <template-origin-api>` object and
+        ``status`` is a string with the reason the template wasn't found.
+
+    ``chain``
+        A list of intermediate :exc:`~django.template.TemplateDoesNotExist`
+        exceptions raised when trying to load a template. This is used by
+        functions, such as :func:`~django.template.loader.get_template`, that
+        try to load a given template from multiple engines.
+
+    .. versionadded:: 1.9
+
+        The ``backend``, ``tried``, and ``chain`` arguments were added.
+
+.. exception:: TemplateSyntaxError(msg)
 
     This exception is raised when a template was found but contains errors.
 
@@ -478,7 +499,6 @@ fictional ``foobar`` template library::
 
             self.engine = foobar.Engine(**options)
 
-
         def from_string(self, template_code):
             try:
               return Template(self.engine.from_string(template_code))
@@ -489,7 +509,7 @@ fictional ``foobar`` template library::
             try:
                 return Template(self.engine.get_template(template_name))
             except foobar.TemplateNotFound as exc:
-                raise TemplateDoesNotExist(exc.args)
+                raise TemplateDoesNotExist(exc.args, backend=self)
             except foobar.TemplateCompilationFailed as exc:
                 raise TemplateSyntaxError(exc.args)
 
@@ -510,6 +530,117 @@ fictional ``foobar`` template library::
 
 See `DEP 182`_ for more information.
 
+.. _template-debug-integration:
+
+Debug integration for custom engines
+------------------------------------
+
+.. versionadded:: 1.9
+
+    Debug page integration for non-Django template engines was added.
+
+The Django debug page has hooks to provide detailed information when a template
+error arises. Custom template engines can use these hooks to enhance the
+traceback information that appears to users. The following hooks are available:
+
+.. _template-postmortem:
+
+Template postmortem
+~~~~~~~~~~~~~~~~~~~
+
+The postmortem appears when :exc:`~django.template.TemplateDoesNotExist` is
+raised. It lists the template engines and loaders that were used when trying
+to find a given template. For example, if two Django engines are configured,
+the postmortem will appear like:
+
+.. image:: _images/postmortem.png
+
+Custom engines can populate the postmortem by passing the ``backend`` and
+``tried`` arguments when raising :exc:`~django.template.TemplateDoesNotExist`.
+Backends that use the postmortem :ref:`should specify an origin
+<template-origin-api>` on the template object.
+
+Contextual line information
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If an error happens during template parsing or rendering, Django can display
+the line the error happened on. For example:
+
+.. image:: _images/template-lines.png
+
+Custom engines can populate this information by setting a ``template_debug``
+attribute on exceptions raised during parsing and rendering. This attribute
+is a :class:`dict` with the following values:
+
+* ``'name'``: The name of the template in which the exception occurred.
+
+* ``'message'``: The exception message.
+
+* ``'source_lines'``: The lines before, after, and including the line the
+  exception occurred on. This is for context, so it shouldn't contain more than
+  20 lines or so.
+
+* ``'line'``: The line number on which the exception occurred.
+
+* ``'before'``: The content on the error line before the token that raised the
+  error.
+
+* ``'during'``: The token that raised the error.
+
+* ``'after'``: The content on the error line after the token that raised the
+  error.
+
+* ``'total'``: The number of lines in ``source_lines``.
+
+* ``'top'``: The line number where ``source_lines`` starts.
+
+* ``'bottom'``: The line number where ``source_lines`` ends.
+
+Given the above template error, ``template_debug`` would look like::
+
+    {
+        'name': '/path/to/template.html',
+        'message': "Invalid block tag: 'syntax'",
+        'source_lines': [
+            (1, 'some\n'),
+            (2, 'lines\n'),
+            (3, 'before\n'),
+            (4, 'Hello {% syntax error %} {{ world }}\n'),
+            (5, 'some\n'),
+            (6, 'lines\n'),
+            (7, 'after\n'),
+            (8, ''),
+        ],
+        'line': 4,
+        'before': 'Hello ',
+        'during': '{% syntax error %}',
+        'after': ' {{ world }}\n',
+        'total': 9,
+        'bottom': 9,
+        'top': 1,
+    }
+
+.. _template-origin-api:
+
+Origin API and 3rd-party integration
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Django templates have an :class:`~django.template.base.Origin` object available
+through the ``template.origin`` attribute. This enables debug information to be
+displayed in the :ref:`template postmortem <template-postmortem>`, as well as
+in 3rd-party libraries, like the `Django Debug Toolbar`_.
+
+Custom engines can provide their own ``template.origin`` information by
+creating an object that specifies the following attributes:
+
+* ``'name'``: The full path to the template.
+
+* ``'template_name'``: The relative path to the template as passed into the
+  the template loading methods.
+
+* ``'loader_name'``: An optional string identifying the function or class used
+  to load the template, e.g. ``django.template.loaders.filesystem.Loader``.
+
 .. currentmodule:: django.template
 
 .. _template-language-intro:
@@ -687,3 +818,4 @@ Implementing a custom context processor is as simple as defining a function.
 
 .. _Jinja2: http://jinja.pocoo.org/
 .. _DEP 182: https://github.com/django/deps/blob/master/accepted/0182-multiple-template-engines.rst
+.. _Django Debug Toolbar: https://github.com/django-debug-toolbar/django-debug-toolbar

+ 31 - 0
tests/template_backends/jinja2/template_backends/syntax_error2.html

@@ -0,0 +1,31 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+{% block %}
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15

+ 2 - 1
tests/template_backends/test_dummy.py

@@ -37,8 +37,9 @@ class TemplateStringsTests(SimpleTestCase):
         self.assertEqual(content, "Hello world!\n")
 
     def test_get_template_non_existing(self):
-        with self.assertRaises(TemplateDoesNotExist):
+        with self.assertRaises(TemplateDoesNotExist) as e:
             self.engine.get_template('template_backends/non_existing.html')
+        self.assertEqual(e.exception.backend, self.engine)
 
     def test_get_template_syntax_error(self):
         # There's no way to trigger a syntax error with the dummy backend.

+ 42 - 0
tests/template_backends/test_jinja2.py

@@ -4,6 +4,8 @@ from __future__ import absolute_import
 
 from unittest import skipIf
 
+from django.template import TemplateSyntaxError
+
 from .test_dummy import TemplateStringsTests
 
 try:
@@ -22,6 +24,16 @@ class Jinja2Tests(TemplateStringsTests):
     backend_name = 'jinja2'
     options = {'keep_trailing_newline': True}
 
+    def test_origin(self):
+        template = self.engine.get_template('template_backends/hello.html')
+        self.assertTrue(template.origin.name.endswith('hello.html'))
+        self.assertEqual(template.origin.template_name, 'template_backends/hello.html')
+
+    def test_origin_from_string(self):
+        template = self.engine.from_string('Hello!\n')
+        self.assertEqual(template.origin.name, '<template>')
+        self.assertEqual(template.origin.template_name, None)
+
     def test_self_context(self):
         """
         Using 'self' in the context should not throw errors (#24538).
@@ -32,3 +44,33 @@ class Jinja2Tests(TemplateStringsTests):
         template = self.engine.from_string('hello {{ foo }}!')
         content = template.render(context={'self': 'self', 'foo': 'world'})
         self.assertEqual(content, 'hello world!')
+
+    def test_exception_debug_info_min_context(self):
+        with self.assertRaises(TemplateSyntaxError) as e:
+            self.engine.get_template('template_backends/syntax_error.html')
+        debug = e.exception.template_debug
+        self.assertEqual(debug['after'], '')
+        self.assertEqual(debug['before'], '')
+        self.assertEqual(debug['during'], '{% block %}')
+        self.assertEqual(debug['bottom'], 1)
+        self.assertEqual(debug['top'], 0)
+        self.assertEqual(debug['line'], 1)
+        self.assertEqual(debug['total'], 1)
+        self.assertEqual(len(debug['source_lines']), 1)
+        self.assertTrue(debug['name'].endswith('syntax_error.html'))
+        self.assertTrue('message' in debug)
+
+    def test_exception_debug_info_max_context(self):
+        with self.assertRaises(TemplateSyntaxError) as e:
+            self.engine.get_template('template_backends/syntax_error2.html')
+        debug = e.exception.template_debug
+        self.assertEqual(debug['after'], '')
+        self.assertEqual(debug['before'], '')
+        self.assertEqual(debug['during'], '{% block %}')
+        self.assertEqual(debug['bottom'], 26)
+        self.assertEqual(debug['top'], 5)
+        self.assertEqual(debug['line'], 16)
+        self.assertEqual(debug['total'], 31)
+        self.assertEqual(len(debug['source_lines']), 21)
+        self.assertTrue(debug['name'].endswith('syntax_error2.html'))
+        self.assertTrue('message' in debug)

+ 22 - 6
tests/template_loader/tests.py

@@ -36,9 +36,10 @@ class TemplateLoaderTests(SimpleTestCase):
         with self.assertRaises(TemplateDoesNotExist) as e:
             get_template("template_loader/unknown.html")
         self.assertEqual(
-            e.exception.tried[-1][0].template_name,
+            e.exception.chain[-1].tried[0][0].template_name,
             'template_loader/unknown.html',
         )
+        self.assertEqual(e.exception.chain[-1].backend.name, 'django')
 
     def test_select_template_first_engine(self):
         template = select_template(["template_loader/unknown.html",
@@ -64,13 +65,15 @@ class TemplateLoaderTests(SimpleTestCase):
             select_template(["template_loader/unknown.html",
                              "template_loader/missing.html"])
         self.assertEqual(
-            e.exception.tried[0][0].template_name,
+            e.exception.chain[0].tried[0][0].template_name,
             'template_loader/unknown.html',
         )
+        self.assertEqual(e.exception.chain[0].backend.name, 'dummy')
         self.assertEqual(
-            e.exception.tried[-1][0].template_name,
+            e.exception.chain[-1].tried[0][0].template_name,
             'template_loader/missing.html',
         )
+        self.assertEqual(e.exception.chain[-1].backend.name, 'django')
 
     def test_select_template_tries_all_engines_before_names(self):
         template = select_template(["template_loader/goodbye.html",
@@ -98,9 +101,10 @@ class TemplateLoaderTests(SimpleTestCase):
         with self.assertRaises(TemplateDoesNotExist) as e:
             render_to_string("template_loader/unknown.html")
         self.assertEqual(
-            e.exception.tried[-1][0].template_name,
+            e.exception.chain[-1].tried[0][0].template_name,
             'template_loader/unknown.html',
         )
+        self.assertEqual(e.exception.chain[-1].backend.name, 'django')
 
     def test_render_to_string_with_list_first_engine(self):
         content = render_to_string(["template_loader/unknown.html",
@@ -126,13 +130,25 @@ class TemplateLoaderTests(SimpleTestCase):
             render_to_string(["template_loader/unknown.html",
                               "template_loader/missing.html"])
         self.assertEqual(
-            e.exception.tried[0][0].template_name,
+            e.exception.chain[0].tried[0][0].template_name,
             'template_loader/unknown.html',
         )
+        self.assertEqual(e.exception.chain[0].backend.name, 'dummy')
         self.assertEqual(
-            e.exception.tried[-1][0].template_name,
+            e.exception.chain[1].tried[0][0].template_name,
+            'template_loader/unknown.html',
+        )
+        self.assertEqual(e.exception.chain[1].backend.name, 'django')
+        self.assertEqual(
+            e.exception.chain[2].tried[0][0].template_name,
+            'template_loader/missing.html',
+        )
+        self.assertEqual(e.exception.chain[2].backend.name, 'dummy')
+        self.assertEqual(
+            e.exception.chain[3].tried[0][0].template_name,
             'template_loader/missing.html',
         )
+        self.assertEqual(e.exception.chain[3].backend.name, 'django')
 
     def test_render_to_string_with_list_tries_all_engines_before_names(self):
         content = render_to_string(["template_loader/goodbye.html",