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
frompyramid.response
- We will decorate each of our views with
view_config
where therenderer
isstring
androute_name
is the appropriate route name for that view - We don’t need the
includeme
function, asview_config
and other configuration decorators are found automatically byconfig.scan()
. - In
__init__.py
we don’t have to useconfig.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.