Pyramid Views, Templates, and Front-end Testing

Last time, we got started with a Pyramid app with the intention to display some expenses online. Toward this end, we created a basic view that read raw a HTML file and provided its content to the browser as an HTTP response. That worked, but it’s a really crappy way to use Pyramid and doesn’t even begin to take advantage of its capabilities. Today we’ll learn about better ways to connect views to the HTML they serve, and how to use templates to display our information.

The MVC View

We return again to the Model-View-Controller pattern. We’ve built our controller using Pyramid’s view callables that take HTTP requests as arguments. We’ve also created the beginnings of our MVC view using our HTTP files. Let’s dig into both of these some more.

Presenting Data

The job of the view in the MVC pattern is to present data in a format that is readable to the users of the system. There are many ways to present data. Some are readable by humans (e.g. tables, charts, graphs, HTML pages, text files), while others are more for machines (e.g. xml files, csv, json).

Which of these formats is the right one depends on your purpose. What is the purpose of our Expense Tracker?

Pyramid Renderers and view_config

In part one of our introduction to Pyramid, we created our first view like so:

from pyramid.response import Response

def home_page(request):
    return Response("This is my first view!")

By doing so, we conflated the tasks of the MVC controller and the MVC view. This is simple, and allows us to get started, but it is not the preferred method. More typically, we allow a view callable to function solely as a controller, and delegate data presentation to a renderer

Renderers in Pyramid come in a variety of types. There are three renderers built-in: string, json, and jsonp. With plugins, like pyramid_jinja2, we can extend this list by adding renderers that implement popular templating languages.

Pyramid renderers take a Python object (a string, list or dict) and return it as an HTTP response. The Content-Type header of the response is set automatically, depending on the renderer used.

We associate a renderer with a view using configuration. We can extend the imperative configuration we used yesterday with a renderer argument:

# in __init__.py

from .default import home_page

def includeme(config):
    """List of views to include for the configurator object."""
    config.add_view(home_page, route_name='home', renderer='string')

However, that approach is also considered to be a bit awkward. The problem is that it separates the configuration of a view from the location of the view callable. This makes it more difficult to quickly understand what each part of your code is doing.

Instead, let’s use a declarative style with the @view_config decorator:

# views.py
# ...
from pyramid.view import view_config

# ...
@view_config(route_name='home', renderer='string')
def home_view(request):
    return "This was transformed into an HTTP response"

# ...

Finally, since we’re now connecting our view to our route with this decorator (and we will forever onward), we’ll need to refactor our code a bit...

  • We no longer need to import or use Response from pyramid.response
  • We will decorate each of our views with view_config where the renderer is string and route_name is the appropriate route name for that view
  • We don’t need the includeme function, as view_config and other configuration decorators are found automatically by config.scan().
  • In __init__.py we don’t have to use config.include('.views') for the same reason as above.

After refactoring, our code should look something like this:

# __init__.py
from pyramid.config import Configurator


def main(global_config, **settings):
    """ This function returns a Pyramid WSGI application.
    """
    config = Configurator(settings=settings)
    config.include('pyramid_jinja2')
    config.include('.routes')
    config.scan()
    return config.make_wsgi_app()
# views.py

from pyramid.view import view_config

@view_config(route_name='home', renderer='string')
def home_view(request):
    return "Home!"

Notice that for the above view, anything that was returned was printed to the browser as a string. We did not have to have it wrapped in an HTTP response object. Check your browser and verify that the Content-Type of the response was text/plain.

As we noted above, we can attach external renderers to our views as well. We have in fact included one in __init__.py with the pyramid_jinja2 package. Recall:

# __init__.py
config.include('pyramid_jinja2')

The pyramid_jinja2 package supports using the Jinja2 template language. Let’s learn a bit about how Jinja2 templates work.

Jinja2 Template Basics

We’ll start with the absolute basics. Fire up an iPython interpreter in your virtual environment and import the Template class from the jinja2 package:

(ENV) bash-3.2$ ipython
...
In [1]: from jinja2 import Template

A template is constructed with a simple string:

In [2]: t1 = Template("Hello {{ name }}, how are you")

Here, we’ve typed the string directly, but it is more common to build a template from the contents of a file.

Notice that our string has some odd stuff in it: {{ name }}. This is called a placeholder (it is marked as such by the double curly braces). When the template is rendered any placeholder in our template is replaced.

Where does the template engine find the values to use in place of the placeholders? We are responsible for passing them to the template in the form of context. Context is passed as keyword arguments to the render method of the template. The names of the provided keyword arguments map onto the names of the placeholders in the template.

We can see this if we call t1‘s render method, providing context for {{name}}:

In [3]: t1.render(name="Freddy")
Out[3]: 'Hello Freddy, how are you'

In [4]: t1.render(name="Gloria")
Out[4]: 'Hello Gloria, how are you'

Note the resemblance to the string formatting we’ve seen before:

In [5]: "This is {owner}'s string".format(owner="Cris") # <-- this is straight Python, NOT Jinja2
Out[5]: 'This is Cris's string'

Dictionaries passed in as part of the context can be addressed with either subscription or dotted notation:

In [6]: person = {'first_name': 'Frank',
   ...:           'last_name': 'Herbert'}
In [7]: t2 = Template("{{ person.last_name }}, {{ person['first_name'] }}")
In [8]: t2.render(person=person)
Out[8]: 'Herbert, Frank'
  • Jinja2 will try the correct way first (attr for dotted, item for subscript).
  • If nothing is found, it will try the opposite.
  • If still nothing, it will return an undefined object.

The exact same is true of objects passed in as part of context:

In [9]: t3 = Template("{{ obj.first_attr }} + {{ obj['second_attr'] }} = Fun!")
In [10]: class Game(object):
   ...:     first_attr = 'babies'
   ...:     second_attr = 'bubbles'
   ...:
In [11]: bathtime = Game()
In [12]: t3.render(obj=bathtime)
Out[12]: 'babies + bubbles = Fun!'

This means your templates can be agnostic as to the nature of the things found in context.

You can apply filters to the data passed in context with the pipe (‘|’) operator:

In [13]: t4 = Template("shouted: {{ phrase|upper }}")
In [14]: t4.render(phrase="this is very important")
Out[14]: 'shouted: THIS IS VERY IMPORTANT'

You can also chain filters together:

In [15]: t5 = Template("confusing: {{ phrase|upper|reverse }}")
In [16]: t5.render(phrase="howdy doody")
Out[16]: 'confusing: YDOOD YDWOH'

Logical control structures are also available. They are marked by a curly brace combined with a percent sign ({% control_name %}):

In [17]: tmpl = """
   ....: {% for item in list %}{{ item}}, {% endfor %}
   ....: """
In [18]: t6 = Template(tmpl)
In [19]: t6.render(list=['a', 'b', 'c', 'd', 'e'])
Out[19]: '\na, b, c, d, e, '

Any control structure introduced in a template must be paired with an explicit closing tag ({% for %} ... {% endfor %}, {% if %} ... {% elif %} ... {% else %} ... {% endif %}).

Remember, although template tags like {% for %} or {% if %} look a lot like Python, they are not. The syntax is specific and must be followed correctly.

There are a number of specialized tests available for use with the if...elif...else control structure:

In [20]: tmpl = """
   ....: {% if phrase is upper %}
   ....:   {{ phrase|lower }}
   ....: {% elif phrase is lower %}
   ....:   {{ phrase|upper }}
   ....: {% else %}{{ phrase }}{% endif %}"""
In [21]: t7 = Template(tmpl)
In [22]: t7.render(phrase="FOO")
Out[22]: '\n\n  foo\n'
In [23]: t7.render(phrase='bar')
Out[23]: '\n\n  BAR\n'
In [24]: t7.render(phrase='This should print as-is')
Out[24]: '\nThis should print as-is'

Basic Python-like expressions are also supported:

In [25]: tmpl = """
   ....: {% set sum = 0 %}
   ....: {% for val in values %}
   ....: {{ val }}: {{ sum + val }}
   ....:   {% set sum = sum + val %}
   ....: {% endfor %}
   ....: """
In [26]: t8 = Template(tmpl)
In [27]: t8.render(values=range(1, 11))
Out[27]: '\n\n\n1: 1\n  \n\n2: 3\n  \n\n3: 6\n  \n\n4: 10\n
          \n\n5: 15\n  \n\n6: 21\n  \n\n7: 28\n  \n\n8: 36\n
          \n\n9: 45\n  \n\n10: 55\n  \n\n'

Templates Applied

There’s more that Jinja2 templates can do, but it is easier to introduce you to that in the context of a working template. So let’s make some.

We have a Pyramid view that’ll return the content of a single expense. Let’s create a template to show it. In expense_tracker/templates/ create a new file detail.jinja2:

<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <article>
            <h1>LJ - Day 12</h1>
            <hr />
            <p>Created <strong>Aug 23, 2016</strong></p>
            <hr />
            <p>Sample body text.</p>
        </article>
    </body>
</html>

We’ll start with this static template, without trying placeholders yet. Notice that the file type is .jinja2, not .html.

Let’s create a detail_view callable to use this new template as a renderer. Then, we can wire up our new detail template renderer to the detail view in expense_tracker/views/default.py:

# views.py
@view_config(route_name='detail', renderer='../templates/detail.jinja2')
def detail_view(request):
    return {} # <--- notice, it returns an empty dictionary

Now we should be able to see some rendered HTML for our expense details. Start up your server:

(ENV) bash-3.2$ pserve development.ini --reload
Starting monitor for PID 29544.
Starting server in PID 29544.
Serving on http://localhost:6543

Then try viewing an individual expense: http://localhost:6543/expenses/1

The HTML in our Jinja2 template comes up just as we’ve structured it! However there’s a problem. If we were to continue on like this we’d still have to create an individual template for every expense. If we just wanted to write static HTML this way, why would we ever use a template?

Jinja2 templates are rendered with a context. A Pyramid view returns a dictionary, which is passed to the renderer as that context. This means we can access values we return from our view in the renderer using the names we assigned to them.

Just like we did in the command line, we can use placeholders. We’ll feed data to those placeholders through the return statement of our detail_view:

# templates/detail.jinja2
<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <article>
            <h1>{{ category }}</h1>
            <hr />
            <p>Created <strong>{{ creation_date }}</strong></p>
            <hr />
            <p>Amount: {{ amount }}</p>
            <p>{{ description }}</p>
        </article>
    </body>
</html>
# views.py
def detail_view(request):
    return {
        "category": "Utilities",
        "creation_date": "Aug 23, 2016",
        "description": "Cable and wireless bill"
        "amount": 100.00
    }

There are some other pieces of data that Pyramid adds to the context you return from your view. One of these is the request object. It gives us access to information that the framework provides automatically.

The Pyramid request has a method called route_url. The job of this method is to return a URL corresponding to some route in your configured application. This allows you to include URLs in your template without needing to know exactly what they will be. The process is called reversing, since it’s a bit like a reverse phone book lookup.

The request also provides an attribute called url that will return the URL for the current page. We can use these features to begin creating connections from our template to other views in our application.

<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <article>
            <h1><a href="{{ request.url }}">{{ category }}</a></h1> # this is new
            <hr />
            <p>Created <strong>{{ creation_date }}</strong></p>
            <hr />
            <p>Amount: {{ amount }}</p>
            <p>{{ description }}</p>
        </article>
        <footer><a href="{{ request.route_url('home') }}">Home</a></footer> # this is also new
    </body>
</html>

Let’s now create a template such that our index shows a list of expenses. We’ll show only the category and the date of creation. In expense_tracker/templates/ create a new file list.jinja2:

<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <h1>Home Page</h1>
        {% if expenses %}
            {% for expense in expenses %}
                <article>
                    <h2><a href="{{ request.route_url('detail', id=expense.id) }}">{{ expense.category }}</a></h2>
                    <hr />
                    <p>Created <strong>{{ expense.creation_date }}</strong></p>
                    <p>Amount: {{ expense.amount }}</p>
                </article>
            {% endfor %}
        {% else %}
            <p>You have no expenses to list.</p>
        {% endif %}
        <footer><a href="{{ request.route_url('home') }}">Home</a></footer>
    </body>
</html>

It’s worth taking a look at a few specifics of this template.

{% for expense in expenses %}
... # stuff here
{% endfor %}

Pyramid has control structures just like Python, but it is not Python. Every for loop and if block in Pyramid must end with an endfor/endif. As with looping in Python, you can iterate over values that are iterable and render each in turn.

Let’s look at another aspect of the same template.

<a href="{{ request.route_url('detail', id=expense.id) }}">{{ expense.title }}</a>

Before we saw request.route_url used to get the full url of the route named home. Now we’re seeing it used to get the url for the detail route. Note that in routes.py that the detail route requires a value to serve as the id.

# routes.py

...
config.add_route('detail', '/expense/{id:\d+}')

Up to now we’ve been bypassing this by just providing a number that would fit the bill. In this case, we have to provide any keywords we want to reference in the url as an argument to request.route_url.

Finally, you’ll need to connect this new renderer to your listing view. Since we need to display data on the page, we have to feed it data to be displayed.

Let’s add a file in the data directory called expense_data.py with the following listing of expenses:

# expense_tracker/data/expense_data.py
"""Expense data for the expense_tracker app."""

EXPENSES = [
    {
        "category": "Utilities",
        "creation_date": "Aug 19, 2016",
        "id": 10,
        "description": "Wifi and cable for August 2016.",
        "amount": 100.00
    },
    {
        "category": "Credit Debt",
        "creation_date": "Aug 22, 2016",
        "id": 11,
        "description": "Chase Freedom payment of $200.",
        "amount": 200.00
    },
    {
        "category": "Car",
        "creation_date": "Aug 23, 2016",
        "id": 12,
        "description": "Lease payment of $350.",
        "amount": 350.00
    },
]

Now in views/default.py import those expenses and feed them to the context data of the list_view.

from expense_tracker.data.expense_data import EXPENSES

@view_config(route_name='home', renderer='../templates/list.jinja2')
def list_view(request):
    """View for listing expenses."""
    return {"expenses": EXPENSES}

#...

EXPENSES here is a rare global variable. For the time being, we will use it as a substitute for a more robust data source. You’re going to want to use this for your detail view tonight. When we discuss models tomorrow, we’ll dispose of this entirely.

We can now see our list page in all its glory. Let’s try starting the server:

(ENV) bash-3.2$ pserve development.ini --reload
Starting monitor for PID 29544.
Starting server in PID 29544.
Serving on http://localhost:6543

View the home page of your project at http://localhost:6543/. Click on the link to an expense, and it should route you to the detail view.

These views are reasonable, if quite plain. There’s also code between them that’s repeated heavily, and we want to do our best to keep code DRY. Let’s put our templates into something that looks more like a website.

Template Inheritance

Jinja2 allows you to combine templates using something called template inheritance. You can create a basic page structure, and then inherit that structure in other templates.

Let’s make a template for the basic outer structure of our pages. The following code will serve as our page template, and will go into a file called layout.jinja2. Save that file to your templates directory. Here’s the code:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Python Expense Tracker</title>
        <!--[if lt IE 9]><script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script><![endif]-->
    </head>
<body>
<header>
    <nav>
        <ul>
            <li>
                <a href="{{ request.route_url('home') }}">Home</a>
            </li>
        </ul>
    </nav>
</header>
<main>
    <h1>My Expense Tracker</h1>
    <section id="content">
        {% block body %}{% endblock %}
    </section>
</main>
</body>
<footer>
    <p>Created in the Code Fellows 401 Python Program</p>
</footer>
</html>

The important part here is the {% block body %}...{% endblock %} expression. This is a template block and it is a kind of placeholder. Other templates can inherit from this one, and fill that block with additional HTML.

Let’s update our detail and list templates:

{% extends "layout.jinja2" %}
{% block body %}
<!-- the meat for each page goes here -->
{% endblock %}

Start the server so we can see the result.

(ENV) bash-3.2$ pserve development.ini --reload
Starting monitor for PID 31312.
Starting server in PID 31312.
Serving on http://localhost:6543

Start at the home page, click on an expense, and it should still work. Now, you’ve shared page structure that is in both.

Static Assets

Although we have a shared structure, it isn’t particularly nice to look at. Aspects of how a website looks are controlled by CSS (Cascading Style Sheets). Stylesheets are one of what we generally speak of as static assets. Other static assets include images that are part of the site’s design (logos, button images, etc) and JavaScript files that add client-side dynamic behavior.

Serving static assets in Pyramid requires adding a static view to configuration. Luckily, it’s a simple addition for us to get and serve these assets.

# in expense_tracker/__init__.py
# ...
def main(global_config, **settings):
    """ This function returns a Pyramid WSGI application.
    """
    config = Configurator(settings=settings)
    config.include('pyramid_jinja2')
    # config.include('.models')
    config.include('.routes')
    # add this next line
    config.add_static_view(name='static', path='expense_tracker:static')
    config.scan()
    return config.make_wsgi_app()
  • The first argument we have provided to add_static_view is a name that must appear in the path of URLs requesting assets.
  • The second argument is a path that is relative to the package being configured. Assets referenced by the name in a URL will be searched for in the location defined by the path.
  • Additional keyword arguments control other aspects of how the static file directory works.

Once you have a static view configured, you can use assets in that location in templates. The request object in Pyramid also provides a static_path method that will render an appropriate asset path for us.

Add the following to your layout.jinja2 template:

<head>
  # stuff that was here before
  <link href="{{ request.static_path('expense_tracker:static/style.css') }}" rel="stylesheet" />
</head>
# everything else

The one required argument to request.static_path is a path to an asset. Note that because any package might define a static view with the directory name static, we have to specify which package we want to look in. That’s why we have expense_tracker:static/style.css in our call.

Create a very basic style for your expense tracker and add it to expense_tracker/static. Then, start up your web server and see what a difference a little style makes.

Enhancing our Tests

It’s time to upgrade our tests just a bit. We’ve changed our app significantly. Let’s write tests that actually pass with the current structure of our application. Let’s also write more tests that show it’s wired together in the right ways.

Updating Existing Tests

Our old tests don’t take into account any of the changes in the views we’ve made thus far. Let’s fix that with some modifications to what we had.

# tests.py
from pyramid import testing
from expense_tracker.data.expense_data import EXPENSES

def test_list_view_returns_dict():
    """Home view returns a Response object."""
    from expense_tracker.views.default import list_view
    request = testing.DummyRequest()
    response = list_view(request)
    assert isinstance(response, dict)

def test_list_view_returns_proper_amount_of_content():
    """Home view response has file content."""
    from expense_tracker.views.default import list_view
    request = testing.DummyRequest()
    response = list_view(request)
    assert len(response['expenses']) == len(EXPENSES)

Now we’re checking that the response coming back from the list_view (formerly our home_page) is in the form we expect and has at least the right amount of data that we asked for.

When we run our tests, we see that our list_view is pretty well covered.

---------- coverage: platform darwin, python 3.6.1-final-0 -----------
Name                               Stmts   Miss  Cover   Missing
----------------------------------------------------------------
expense_tracker/views/default.py       7      2    71%   15-16

We however have another view: the detail_view. Let’s add some tests for that as well:

Recall that our detail_view returns a dictionary, with the “category”, “creation_date”, “id”, “description”, and “amount” of a given expense. We make our tests reflect that. First we check that what was returned by our view callable is a dictionary.

def test_detail_view():
    """Test that what's returned by the view is a dictionary of values."""
    from expense_tracker.views.default import detail_view
    request = testing.DummyRequest()
    info = detail_view(request)
    assert isinstance(info, dict)

Then we check that what’s in the dictionary matches what we expect. Let’s see that the appropriate keys are present in the output of the view.

def test_detail_view_response_contains_expense_attrs():
    """Test that what's returned by the view contains one expense object."""
    from expense_tracker.views.default import detail_view
    request = testing.DummyRequest()
    info = detail_view(request)
    for key in ["category", "creation_date", "id", "description", "amount"]:
        assert key in info.keys()

Running our test suite now shows that our views are 100% covered with unit tests!

———- coverage: platform darwin, python 3.6.1-final-0 ———– Name Stmts Miss Cover Missing —————————————————————- expense_tracker/views/default.py 7 0 100%

Our journey is not yet finished, however, as we have nothing covering how our application is wired together.

Enter Functional Tests

If we modify our .coveragerc to reflect more of how our app interacts with itself:

[run]
source = expense_tracker
omit =
    expense_tracker/test*
    expense_tracker/models/*
    expense_tracker/scripts/*
    expense_tracker/data/*
    expense_tracker/views/notfound.py
    expense_tracker/views/__init__.py # <--- remove this line
    expense_tracker/routes.py # <--- remove this line
    expense_tracker/__init__.py # <--- remove this line

[report]
show_missing = True

we find that when we run our tests we’re not covering very much at all.

---------- coverage: platform darwin, python 3.6.1-final-0 -----------
Name                                Stmts   Miss  Cover   Missing
-----------------------------------------------------------------
expense_tracker/__init__.py             7      5    29%   7-12
expense_tracker/routes.py               4      3    25%   2-4
expense_tracker/views/__init__.py       0      0   100%
expense_tracker/views/default.py        7      0   100%
-----------------------------------------------------------------
TOTAL                                  18      8    56%

We’re missing the functionality of our application.

As the name implies, functional tests verify that various components of our app function together as they should. They are an important complement to unit tests, which ensure that each individual piece does the job it is designed to do.

We’ll use functional tests to “look” at our front-end and make sure that what’s being displayed is what we expect. In order for this to happen though, we need to have a working web app to test.

We first create a pytest fixture to take the place of a “working” web app.

import pytest # <--- put this at the top of tests.py

...

@pytest.fixture()
def testapp():
    """Create an instance of our app for testing."""
    from expense_tracker import main
    app = main({})
    from webtest import TestApp
    return TestApp(app)

Here, we set up a new WSGI app instance by providing our main function, which is already taking our base global configuration, with an empty dict for settings. The TestApp class from the WebTest package wraps that app to provide some additional functionality, such as the ability to send GET requests to a given route. WebTest is a great app for testing functionality with respect to real HTTP requests, but it can do so much more (even check for cookies!). Read the documentation for more details about how WebTest can help you write robust tests for your web app.

@pytest.fixture()
def testapp():
    """Create an instance of our app for testing."""
    from expense_tracker import main
    app = main({})
    from webtest import TestApp
    return TestApp(app)

def test_layout_root(testapp):
    """Test that the contents of the root page contains <article>."""
    response = testapp.get('/', status=200)
    html = response.html
    assert 'Created in the Code Fellows 401 Python Program' in html.find("footer").text

def test_root_contents(testapp):
    """Test that the contents of the root page contains as many <article> tags as expenses."""
    from expense_tracker.data.expense_data import EXPENSES
    response = testapp.get('/', status=200)
    html = response.html
    assert len(EXPENSES) == len(html.findAll("article"))

For each of these functional tests, we send an actual GET request to a route. We then test for the contents of the body, looking at output that should come first from the layout template, and then from actual generated content.

The output from the response.html object acts a lot like “soup” objects from the BeautifulSoup package. The idea with BeautifulSoup is you can walk through the DOM tree like you normally would with something like jQuery. The methods on html allow you to do that walking, as well as searching through the DOM for elements, IDs, class names, elements with certain attributes, etc.

html.find() lets you find the first instance of a given HTML element, while html.find_all() allows us to find...all of a given element. Each element allows us to look for the text within with .text attribute or the .get_text() method. Fire this code up in pshell and see the wide array of things available to you!

Now, run tox and look at that sweet, sweet coverage:

---------- coverage: platform darwin, python 3.6.1-final-0 ----------
Name                                Stmts   Miss  Cover   Missing
-----------------------------------------------------------------
expense_tracker/__init__.py             7      0   100%
expense_tracker/routes.py               4      0   100%
expense_tracker/views/__init__.py       0      0   100%
expense_tracker/views/default.py        7      0   100%
-----------------------------------------------------------------
TOTAL                                  18      0   100%

Woo!

Recap

Today’s work involved a lot of refactoring, switching to Jinja2 templates, and finally moving further into testing. Specifically, we used Pyramid’s built-in view_config decorator to wire our views to the appropriate renderers, removing the need to manually include views and connect routes to those views.

We then of course made the appropriate renderers using Jinja2 templates. Within those templates, we used placeholders with Jinja2 syntax to wire the data we wanted into the appropriate places without having to manually include them. We also saw how we could even make our front-end DRY by using template inheritance, creating a master layout.jinja2 template that wrapped each page as needed.

Finally, we stepped into functional tests, seeing how we could validate our front-end after the data has been passed and the page has been rendered. Finally, we saw how we could use the HTML returned by response.html to make our front-end tests more robust, piecing apart the rendered HTML itself and ensuring that the contents of our page match our expectations at a functional level.

Tonight you will use these to update your learning journal app with sensible DRY templates and connections between views and routes using the declarative style of view_config. You will then write comprehensive tests for your individual views as well as your front end. Tomorrow, we’ll stop messing about with hard-coded data and learn how we can use Pyramid models and SQL persistence for a more robust web app.