User Interactions and More About Testing¶
Up until now we’ve been able to make a front-end, build out the back-end, and even add a model to persist our data for the long term. The last major piece involves being able to edit that data from the front-end. We need to create some views that handle user interaction.
Working with Forms in Pyramid¶
The whole point of creating a model is so that we can persist data across sessions.
However, without an interface how can we add new data?
Forms allow for user input, and we can use the request
method in a view to handle that input and create new data.
Let’s create a template called new-expense.jinja2
.
In that template, we’ll write the following HTML/Jinja2 code:
{% extends "layout.jinja2" %}
{% block body %}
<form method="POST">
<table>
<tr>
<td><label for="category">Category:</label></td>
<td><input type="text" name="category" required /></td>
</tr>
<tr>
<td><label for="amount">Amount ($USD):</label></td>
<td><input type="number" name="amount" step="0.01" required /></td>
</tr>
<tr>
<td><label for="description">Description:</label></td>
<td>
<textarea name="description" placeholder="Briefly describe your expense">
</textarea>
</td>
</tr>
<tr>
<td></td>
<td><input type="submit" value="Submit New Expense" /></td>
</tr>
</table>
</form>
{% endblock %}
Here we’ve got 3 fields, with convenient names that we can use to pull data into the back-end when the form is submitted.
We don’t have to specify an action
attribute because we can control where the page goes after a submission via the back-end.
We want to add data to our app and not just get data from our app.
Thus, the method on our form needs to be a POST
method.
We use required
for our form fields here to do some front-end data validation, but we’ll definitely add to that on the back-end.
Let’s also create the route that we intend to use.
# in routes.py
def includeme(config):
config.add_static_view('static', 'static', cache_max_age=3600)
config.add_route('home', '/')
config.add_route('detail', '/expense/{id:\d+}')
config.add_route('create', '/new-expense')
And lets connect it to an appropriate view.
# in views/default.py
@view_config(route_name="create", renderer="../templates/new-expense.jinja2")
def create_view(request):
return {}
Run pserve
and look at the new page that was made.
Here we have a simple form.
We can fill out the input field and submit it, and the data that we sent goes...absolutely nowhere.
With pshell
we can check our database and see that nothing new has been added.
In [1]: from expense_tracker.models import get_engine, MyModel
In [2]: engine = get_engine(registry.settings) # default prefixes are 'sqlalchemy.'
In [3]: from sqlalchemy.orm import sessionmaker
In [4]: Session = sessionmaker(bind=engine)
In [5]: session = Session()
In [6]: session.query(MyModel).count()
Out[6]: 4
We need to configure our view such that it can do more than just display the form. We need it to take the data submitted in the form and do something with it.
Harvesting Form Data¶
Let’s get a hold on the data first.
For this we need to look at the request
object.
We can inspect the request
object in our interpreter and see it has tons of attributes and methods.
In [7]: request.
Display all 120 possibilities? (y or n)
request.GET request.is_body_seekable
request.POST request.is_response
... ...
If you submit a form, the form’s data will be a part of the method
it was submitted with.
Whether it’s a GET
or a POST
method, that data will exist within a MultiDict
object.
For our purposes it acts the same as a Python dictionary.
With the GET
or POST
multidict, the name of the form field will be a key in the multidict.
The request
object also has an attribute called .method
that holds the type of HTTP method used to call up the page.
No matter what, when we first load the page it’ll be with a ``GET`` request.
The only time a POST
request is sent is when a form is submitted.
Knowing this, we can begin to reconfigure our create_view
function.
When we submit our form with a POST
request, we want to pull the data from the request
object, and use it to create a new Expense
object.
We can then add that new Expense
object to the request.dbsession
.
# at the top of views/default.py...
import datetime
# underneath all the rest of the views...
@view_config(route_name='create', renderer="../templates/new-expense.jinja2")
def create_view(request):
"""Creating a new instance of the Expense object."""
if request.method == "POST":
form_data = request.POST
new_expense = Expense(
category=form_data['category'],
description=form_data['description'],
creation_date=datetime.datetime.now(),
amount=float(form_data['amount'])
)
request.dbsession.add(new_expense)
return {}
return {}
Now when we submit new data via our form, as long as it hits all of the correct fields specified by the required
attribute in our HTML, a new Expense
gets created and added to the database.
How do you think the edit_view
works?
Likely in much the same way, except that the form should start out with all the data that’s currently present on the object being requested.
There are checks in place to make sure that the item being accessed actually exists, like with the detail_view
.
Then there’s another check to see if the request is a GET
or POST
request to determine what to do with the data, like with the create_view
above.
Its true form will lie somewhere in between those two.
Try to work it out for yourself!
Cleaning Up Edge Cases: Missing Data¶
Currently, our create_view
takes whatever data is coming in from the POST
request and uses it to create a new model instance.
This is great, but doesn’t handle any situations where data is missing from the submission form.
For the moment, the front-end we wrote up in Jinja should protect us from such situations, but front-ends change all the time. In other cases, a front-end may not even be necessary to send data to our app.
Submitting a POST request with empty data happens all the time, so we should proof our shiny, new view against that situation by checking if data exists in the incoming POST request.
@view_config(route_name='create', renderer="../templates/new-expense.jinja2")
def create_view(request):
"""Creating a new instance of the Expense object."""
if request.method == "POST" and request.POST:
form_data = request.POST
new_expense = Expense(
category=form_data['category'],
description=form_data['description'],
creation_date=datetime.datetime.now(),
amount=float(form_data['amount'])
)
request.dbsession.add(new_expense)
return {}
return {}
Now our conditional statement checks not only that the incoming request is a POST
request, but that there’s actual data coming along with that request.
We could get more granular and check that every field we want is a part of the POST
multidict, but we won’t here.
Feel free to do that on your own.
We could, however do something a little different. What if only part of the data had been submitted? Should the user have to fill in everything all over again? What if we could use what they’ve already sent to prefill the data that was good?
@view_config(route_name='create', renderer="../templates/new-expense.jinja2")
def create_view(request):
"""Creating a new instance of the Expense object."""
if request.method == "POST" and request.POST:
form_names = ["category", "description", "amount"]
if sum([key in request.POST for key in form_names]) == len(form_names):
if '' not in list(request.POST.values()):
form_data = request.POST
new_expense = Expense(
category=form_data['category'],
description=form_data['description'],
creation_date=datetime.datetime.now(),
amount=float(form_data['amount'])
)
request.dbsession.add(new_expense)
return {}
data = request.POST
return data
We’ve added two checks here:
- Are all of the fields we want present in the POST request?
- If all of the fields are present, do they all have some non-null data?
If either of the above two checks fail, whatever data was passed in the POST request gets sent back to the front-end.
Let’s modify our form to take advantage of the data that can get returned. If we’re doing a simple GET request, the POST dictionary should be empty, so our form will work as-is. If a POST request with incomplete data gets sent, we harvest what was completed and re-insert it into the form.
{% extends "layout.jinja2" %}
{% block body %}
<form method="POST">
<table>
<tr>
<td><label for="category">Category:</label></td>
<td><input type="text" name="category" value="{{ category }}" required /></td>
</tr>
<tr>
<td><label for="amount">Amount ($USD):</label></td>
<td><input type="number" name="amount" step="0.01" value="{{ amount }}" required /></td>
</tr>
<tr>
<td><label for="description">Description:</label></td>
<td>
<textarea name="description" placeholder="Briefly describe your expense">
{{ description }}
</textarea>
</td>
</tr>
<tr>
<td></td>
<td><input type="submit" value="Submit New Expense" /></td>
</tr>
</table>
</form>
{% endblock %}
If one of those keys isn’t present, the template will just render nothing in its place.
Redirecting the User¶
When the user submits this form, where does the page go? Currently, it takes them back to the same page, which is great if they want to input multiple expenses. But, what if they just want to go back to the home page and see a listing of all their expenses? Or, what if they want to be redirected to the page displaying the data of their object instance? Enter: HTTP redirects.
Pyramid’s httpexceptions
module contains a ton of HTTPException
objects.
These handle all types of situations that you may encounter when handling an incoming request.
We’ve already used one before: the HTTPNotFound
for indicating a resource wasn’t able to be located.
Here are just a few more of note:
HTTPBadRequest
: for raising a 400 error when the incoming request was bad for any reason (bad syntax, missing data, etc.)HTTPForbidden
: raised when the user isn’t permitted to view the page they’re attempting to viewHTTPNoContent
: request was successful and there was nothing to return back to the userHTTPCreated
: request was successful and the object you intended to create was in fact createdHTTPFound
: redirect the user to a different location (we’ll be using this one)
To redirect the user, we have to return HTTPFound
and provide it with the location of where we want to send the user next as an argument.
It contains a status code 302, and will simply send the user to the next spot.
If we wanted to send the user to the home page, we would return HTTPFound(location=request.route_url('home'))
.
What about the specific detail page for the expense that was just created?
Remember, until this particular view finishes its session and directs the user elsewhere, no changes to the database are committed.
So it’s not as simple as return HTTPFound(request.route_url('detail', id=new_expense.id))
.
It’s close though.
Figure it out!
Testing, Testing, and More Testing¶
From the side of functional code, we’ve added one new thing that needs testing: the create_view
.
On the front-end side, we created the new-expense.jinja2
template.
We need to test both of these thoroughly to be sure that our application works exactly how it should.
Plan The Work...¶
Before we write these tests, let’s consider what tests we should write.
How should our app work?
Based on the code above, the create_view
itself as a function should do the following:
Take a
GET request
object as an argument and return any empty dict-like object.- Take a
POST request
object as an argument and... if there is no data attached, return an empty dict-like object.
if the data attached is incomplete return a dict-like object with what data WAS complete.
if the key-value data attached is not the data we’re looking for, return a dict-like object with whatever data was included.
if the appropriate key-value data is attached but any of the values are empty, return a dict-like object with whatever data was included.
- if the appropriate key-value data is attached and every field is filled...
- create a new expense object.
- return a status code 302
- return a response-like object attempting to redirect somewhere else (home page, detail page, whatever you set)
- Take a
That’s at least 8 unit tests for the ``create_view`` callable.
What about the functional tests?
How should this new code interact with the fully wired-together application?
What should happen when the create
route is accessed?
With a
GET
request, an empty form should be rendered in the HTML with all of the appropriate form fields and a button for submitting. Each form field element should have aname
that’s used for the back-end when processing submitted data.- With a
POST
request... if there is no data attached, the above form should be rendered in the HTML.
if the data attached is incomplete, whatever data WAS complete should be rendered in the form as the
value
of its appropriate field.if the data attached is not the data we need, whatever data WAS valid should be rendered in the form as above.
if the data attached has the right field names but empty values, re-render the form.
- if the data attached is all appropriate and every field is filled...
- a new expense object is created.
- a status code 302 is in the response.
- if the redirection is followed, you land on whatever page is expected to be at the end of that redirect
- the home page that lists all your expenses should include the new expense.
- the detail page for the new expense should exist and be accessible.
- the detail page for the new expense should include the actual expected detail of the new object.
- With a
This is a solid start, and means another 10 functional tests to add to the test stack. Again, we want to test all of the ins-and-outs of our code.
We can use the above listings as our guide for testing. When you plan out code for your own projects and are writing your code in a test-driven way (as you should if you are able), you should do the same before writing a single line of code. It’ll make the testing process that much easier, as we can simply check off the items that we expected to cover as we complete the indiviudal tests!
...and Work the Plan¶
We’ll only cover a few of the newer tests here, as you should already know how to test for status codes and the results of GET
requests.
First, the unit tests.
Unit Test: An Empty POST Request Returns an Empty Dict¶
The DummyRequest
that we’ve been using from pyramid.testing
is a GET
request by default.
How do we change it to be a POST
request?
Recall in the create_view
when we check the method
and the POST
multidict attached to the request
object?
The DummyRequest
instance is essentially the exact same type of object.
So, just like we can check the request.method
to see if it’s a POST
request, we can set the dummy_request.method
to be the POST request we need.
def test_create_view_post_empty_is_empty_dict(dummy_request):
"""POST requests without data should return an empty dictionary."""
from expense_tracker.views.default import create_view
dummy_request.method = "POST"
pass
The dummy_request.POST
multidict is empty to start with.
We can simply supply the modified dummy_request
as-is to our view and check the response.
def test_create_view_post_empty_is_empty_dict(dummy_request):
"""POST requests without data should return an empty dictionary."""
from expense_tracker.views.default import create_view
dummy_request.method = "POST"
response = create_view(dummy_request)
assert response == {}
Unit Test: Incomplete Data Returns What Data Was Sent¶
We need to be able to send some data along with our POST
request, just like when a form is submitted.
What happens is that the form fields populate the request.POST
dict with key-value pairs.
The keys are the name
attributes of the individual form fields.
The values are whatever data has been entered into the form field, or is assigned to the value
attribute of an unchanged field.
If the data doesn’t contain each and every one of the fields we expect, we just send it back to the user to try again.
def test_create_view_post_incomplete_data_returns_data(dummy_request):
"""POST data that is incomplete just gets returned to the user."""
from expense_tracker.views.default import create_view
dummy_request.method = "POST"
data_dict = {"category": random.choice(CATEGORIES)}
dummy_request.POST = data_dict
response = create_view(dummy_request)
assert response == data_dict
Unit Test: Correctly-Submitted Data Returns Status Code 302¶
This should be just like every other status code check we’ve ever written. We should start off the same way.
def test_create_view_post_good_data_is_302(dummy_request):
"""POST request with correct data should redirect with status code 302."""
from expense_tracker.views.default import create_view
dummy_request.method = "POST"
data_dict = {
"category": random.choice(CATEGORIES),
"description": FAKE_FACTORY.text(100),
"amount": random.random() * random.randint(0, 1000)
}
dummy_request.POST = data_dict
response = create_view(dummy_request)
assert response.status_code == 302
When we run our tests, we get a nice big fail. Why does this test fail? Let’s check the output:
__________________________ test_create_view_post_good_data_is_302 ___________________________
dummy_request = <pyramid.testing.DummyRequest object at 0x104ff3358>
- def test_create_view_post_good_data_is_302(dummy_request):
“”“POST request with correct data should redirect with status code 302.”“” from expense_tracker.views.default import create_view dummy_request.method = “POST” data_dict = {
“category”: random.choice(CATEGORIES), “description”: FAKE_FACTORY.text(100), “amount”: random.random() * random.randint(0, 1000)} dummy_request.POST = data_dict
> response = create_view(dummy_request)
expense_tracker/tests.py:166: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ expense_tracker/views/default.py:48: in create_view
return HTTPFound(request.route_url(‘home’))
- ../ENV/lib/python3.6/site-packages/pyramid/url.py:260: in route_url
- mapper = reg.getUtility(IRoutesMapper)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <Registry testing>, provided = <InterfaceClass pyramid.interfaces.IRoutesMapper> name = ‘’
- def getUtility(self, provided, name=u’‘):
- utility = self.utilities.lookup((), provided, name) if utility is None:
> raise ComponentLookupError(provided, name) E zope.interface.interfaces.ComponentLookupError: (<InterfaceClass pyramid.interfaces.IRoutesMapper>, ‘’)
../ENV/lib/python3.6/site-packages/zope/interface/registry.py:286: ComponentLookupError
Well that’s great. But what does it all MEAN?
First let’s note that the error took place within the create_view
(the first frame here).
The next frame shows that the error occurred when our test runner tried to call the HTTPFound
object with the request.route_url('home')
as an argument.
The very next line shows that the error was in route_url
itself.
How did route_url
foul up?
In ENV/lib/python3.6/site-packages/pyramid/url.py
, the route_url
method tries to call the getUtility
method using an iRoutesMapper
object.
Things get more obscure here and are poorly explained by simply diving into the source code.
Effectively what’s happening is that Pyramid is expecting to look up the name of the route that we provided to request.route_url
in whatever has been saved to its registry of all configured routes.
Funny thing about that though, our testing environment has no routes configured to anything.
The result? That nice ComponentLookupError
gets raised and our test dies.
So how do we fix this?
Remember how in expense_tracker/__init__.py
we had config.include('.routes')
?
THAT is where our routes get added to Pyramid’s registry, and THAT’s how request.route_url
knows to look up what "home"
means.
Our configuration is severely lacking in that include
.
So, just like when we included our models
yesterday so that we could test against the database, let’s include our routes
so that our HTTP redirect has somewhere to go.
@pytest.fixture(scope="session")
def configuration(request):
"""Set up a Configurator instance.
This Configurator instance sets up a pointer to the location of the
database.
It also includes the models from your app's model package.
Finally it tears everything down, including the in-memory SQLite database.
This configuration will persist for the entire duration of your PyTest run.
"""
config = testing.setUp(settings={
'sqlalchemy.url': 'postgres:///test_expenses'
})
config.include("expense_tracker.models")
config.include("expense_tracker.routes") # <---- this is the line that should be added
def teardown():
testing.tearDown()
request.addfinalizer(teardown)
return config
When we run our tests again, we see green text and lots of glorious dots.
Note: this test was looking to check that the response from the view contained a redirection for the status code. However, this is a UNIT test. That redirection will go nowhere. We cannot (and should not) follow the redirection, as that defeats the purpose of unit-testing this one function.
A full redirection pipeline is a demonstration of how the codebase is wired together. That just screams “functional test”.
Functional Test: If Redirection is Followed, Result is Home Page¶
Similar to how our unit tests for the create_view
have been working, we’ll need to send a POST
request to our app.
For our functional tests, our app is the testapp
fixture.
Before we dig into this test, we need to add a new test fixture.
Currently, because the testapp
has session
scope, its connection to the test database is open when the tests start, with the tables having been created once and only once.
What this means is that after we add and remove model instances a bunch of times, the table is still there, but with the id
field having been incremented up time and time again.
When we post data through the “front-end” of the testapp
, it’s going to try to start with id=1
.
However, because the table itself has remained, as far as the table is concerned, id=1
has already existed.
You can’t use it again because that’s a primary key field.
All primary key fields must be unique.
So, what to do? We could create a fixture whose only purpose is to nuke the database and create a new table. There are other things we could do, but let’s do that.
@pytest.fixture
def empty_db(testapp):
"""Tear down the database and add a fresh table."""
SessionFactory = testapp.app.registry["dbsession_factory"]
engine = SessionFactory().bind
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
Whenever this function gets called, all existing tables will get dropped then immediately rebuilt anew.
Let’s get back to testing redirection.
Up to this point, we’ve only used the get()
method to access our routes.
However, the testapp
can do so much more than that.
Check out the docs here.
Even more information
Now, it’s time for post()
.
def test_new_expense_redirects_to_home(testapp, empty_db):
"""When redirection is followed, result is home page."""
data_dict = {
"category": random.choice(CATEGORIES),
"description": FAKE_FACTORY.text(100),
"amount": random.random() * random.randint(0, 1000)
}
response = testapp.post('/new-expense', data_dict)
import pdb; pdb.set_trace()
assert False
We don’t directly set some attribute like with the dummy_request
.
Instead, we provide the data directly to the post()
method call.
So, what actually comes back on the response? The breakpoint is there so that we can check.
>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB set_trace (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /Users/Nick/Documents/codefellows/courses/develop-401/expense-tracker/expense_tracker/expense_tracker/tests.py(251)test_new_expense_redirects_to_home()
-> assert False
(Pdb) print(response)
Response: 302 Found
Content-Type: text/plain; charset=UTF-8
Location: http://localhost/
302 Found
The resource was found at http://localhost/; you should be redirected automatically.
Wow, that’s convenient.
It appears that there might be a location
attribute that we could use to figure out where the redirection goes.
(Pdb) response.location
'http://localhost/'
That looks like the full URL of our home page!
It’s not quite what we expect though.
Our routes, as detailed in routes.py
don’t start with the domain name.
They simply contain URI
paths that get concatenated with whatever the domain might be.
There’s a number of ways that we can handle this.
The way we’ll handle it here is to assign the domain name of our test environment to an appropriately-named global variable like SITE_ROOT
or DOMAIN
.
Then we can check that the location
attribute returns a combination of the SITE_ROOT
and the path that we expect the redirect to go to.
# somewhere in tests.py
SITE_ROOT = "http://localhost"
# back amongst the functional tests
def test_new_expense_redirects_to_home(testapp, empty_db):
"""When redirection is followed, result is home page."""
data_dict = {
"category": random.choice(CATEGORIES),
"description": FAKE_FACTORY.text(100),
"amount": random.random() * random.randint(0, 1000)
}
response = testapp.post('/new-expense', data_dict)
home_path = testapp.app.routes_mapper.get_route('home').path
assert response.location == SITE_ROOT + home_path
What is this incantation right before the assert
?
The app
object contains all of the configuration and knowledge of our application.
It is the actual return value of the main
function that constructs our app when we run pserve
or deploy to Heroku.
In the testapp
fixture, this app
object is provided to TestApp
as an argument.
In this way, in addition to all of the application configuration that we need, we also have access to all of the functionality afforded by the TestApp
from webtest
.
Amongst all of the other information it possesses about our application, app
knows about our route names and the paths attached to those names.
The routes_mapper
is the object that handles all of the connections of route names to route paths.
The routes_mapper
has a get_route
method which, as you might imagine, gets the route.
However what get_route
returns is not a string like we need.
get_route
returns a Route
object.
It’s that Route
object that we need to access so that we can get the path
.
And it’s that path
that needs to be concatenated with the domain name to make the full URL.
Ta Da!
Functional Test: Similar to Above, But a Different Way¶
Above we wrote a test that checks the redirect location of a successful form submission against the path to the page that it should redirect to.
That’s one way to do it.
However, an issue is that we’re not actaully checking that you land on the appropriate page. All that we check is that you intend to land on the appropriate page. It’s a small distinction, but part of the reason that we write tests is to be absolutely 100% explicit about what it is that our application is doing at any point at time.
So let’s test that what you get redirected to is, in fact, the home page. We start out very much the same.
def test_new_expense_redirection_lands_on_home(testapp, empty_db):
"""When redirection is followed, result is home page."""
data_dict = {
"category": random.choice(CATEGORIES),
"description": FAKE_FACTORY.text(100),
"amount": random.random() * random.randint(0, 1000)
}
response = testapp.post('/new-expense', data_dict)
assert False
The response
above contains a method called follow
.
When this method is called, it’ll return a new response
object representing the actual followthrough of the redirect.
It’ll be as if we made a separate get
request to the home
route!
...that actually sounds like a good thing to use for our test.
We could havest the return value of response.follow()
, then check its contents against a fresh get
request to the home
route.
def test_new_expense_redirection_lands_on_home(testapp, empty_db):
"""When redirection is followed, result is home page."""
data_dict = {
"category": random.choice(CATEGORIES),
"description": FAKE_FACTORY.text(100),
"amount": random.random() * random.randint(0, 1000)
}
response = testapp.post('/new-expense', data_dict)
next_response = response.follow()
home_response = testapp.get('/')
assert next_response.text == home_response.text
When our tests are run, green text and black dots validate our decision-making. Now we are just a bit more certain about how exactly new view works on its own and in tandem with the rest of our application.
Recap¶
Today’s work focused on creating new data.
We created a template that uses a form to take user input, as well as a view that handles form data.
We investigated the request
object in greater detail, seeing that its .method
, .POST
, and .GET
attributes can allow us to produce different outputs on the same view and template.
Once we put together the view itself, we got serious about testing.
Before we wrote one more new test, we got clear on what we wanted to test by writing out a test plan.
We then set about writing tests that were unique to the create_view
, both for unit and functional tests.
For unit tests we saw that we had to add a little more application configuration before we could handle all of the cases that were needed for this view.
We then saw how we could pass information through a POST
request to our view callable.
After we got solid on unit tests, we dove deep into the functional testing landscape. We saw two distinctly different ways to handle redirection in our web application. Both of these methods tested different aspects of redirection, and each showed us something different about how our app is all wired together.
Our next hit of Pyramid will add User registration, authentication, and authorization to our web app. After that, more about security. Following that, we’ll learn how to our site without a browser, as well as how to use AJAX to add some asynchronicity to our front-end.