Tutorial Handout
Prerequisites and Assumptions
The tutorial uses Python 3.
Required Software Installations
Aside from Python, you’ll need:
-
Firefox
-
Git
-
Pip
-
Selenium
-
Django 1.7 beta
To make sure we’re using the Python3 version of pip, I’ll always use pip3
as the executable in my command-line examples. Depending on your platform, it
may be pip-3.3
or pip-3.4
.
Required Python Modules
Once you have pip installed, it’s trivial to install new Python modules. We’ll install some as we go, but there are a couple we’ll need right from the beginning, so you should install them right away:
-
Django 1.7,
sudo pip3 install django==1.7
(omit thesudo
on Windows). This is our web framework. You should make sure you have version 1.7 installed and that you can access thedjango-admin.py
executable from a command line. The Django documentation has some installation instructions if you need help.
![]() |
As of May 2014, Django 1.7 was still in beta. If the above command
doesn’t work, use
sudo pip3 install https://github.com/django/django/archive/stable/1.7.x.zip |
-
Selenium,
sudo pip3 install --upgrade selenium
(omit thesudo
on Windows)
![]() |
Unless you’re absolutely sure you know what you’re doing, don’t
use a virtualenv . We’ll start using one later in the book, in
[deployment-chapter]. |
Getting Django Set Up Using a Functional Test

Obey the Testing Goat! Do Nothing Until You Have a Test
Here’s about the simplest imaginable test:
from selenium import webdriver
browser = webdriver.Firefox()
browser.get('http://localhost:8000')
assert 'Django' in browser.title
Expected output:
$ python3 functional_tests.py
Traceback (most recent call last):
File "functional_tests.py", line 6, in <module>
assert 'Django' in browser.title
AssertionError
Getting Django Up and Running
$ django-admin.py startproject superlists
That will create a folder called superlists, and a set of files and subfolders inside it:
.
├── functional_tests.py
└── superlists
├── manage.py
└── superlists
├── __init__.py
├── settings.py
├── urls.py
└── wsgi.py
-
ask about: why are there two folders called superlists?
$ python3 manage.py runserver
Validating models...
0 errors found
Django version 1.7, using settings 'superlists.settings'
Development server is running at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Leave that running, and open another command shell. In that, we can try running our test again (from the folder we started in):
$ python3 functional_tests.py
$
Not much action on the command line, but you should notice two things: firstly,
there was no ugly AssertionError
and secondly, the Firefox window that
Selenium popped up had a different-looking page on it.
Well, it may not look like much, but that was our first ever passing test! Hooray!
If it all feels a bit too much like magic, like it wasn’t quite real, why not go and take a look at the dev server manually, by opening a web browser yourself and visiting http://localhost:8000? You should see something like [it_worked_screenshot].

You can quit the development server now if you like, back in the original shell, using Ctrl-C.
Optional: Starting a Git repository
Let’s start by moving functional_tests.py into the superlists folder, and
doing the git init
to start the repository:
$ ls
superlists functional_tests.py
$ mv functional_tests.py superlists/
$ cd superlists
$ git init .
Initialised empty Git repository in /workspace/superlists/.git/
![]() |
From this point onwards, the top-level superlists folder will be our working directory. Whenever I show a command to type in, it will assume we’re in this directory. Similarly, if I mention a path to a file, it will be relative to this top-level directory. So superlists/settings.py means the settings.py inside the second-level superlists. Clear as mud? If in doubt, look for manage.py; you want to be in the same directory as manage.py. |
Now let’s add the files we want to commit—which is everything really!
$ ls
db.sqlite3 manage.py superlists functional_tests.py
db.sqlite3
is a database file. We don’t want to have that in
version control, so we add it to a special file called .gitignore
which, um, tells Git what to ignore:
$ echo "db.sqlite3" >> .gitignore
Next we can add the rest of the contents of the current folder, ".":
$ git add .
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: .gitignore
new file: functional_tests.py
new file: manage.py
new file: superlists/__init__.py
new file: superlists/__pycache__/__init__.cpython-34.pyc
new file: superlists/__pycache__/settings.cpython-34.pyc
new file: superlists/__pycache__/urls.cpython-34.pyc
new file: superlists/__pycache__/wsgi.cpython-34.pyc
new file: superlists/settings.py
new file: superlists/urls.py
new file: superlists/wsgi.py
Oops, remove .pyc files, and .gitignore them:
$ git rm -r --cached superlists/__pycache__
rm 'superlists/__pycache__/__init__.cpython-34.pyc'
rm 'superlists/__pycache__/settings.cpython-34.pyc'
rm 'superlists/__pycache__/urls.cpython-34.pyc'
rm 'superlists/__pycache__/wsgi.cpython-34.pyc'
$ echo "__pycache__" >> .gitignore
$ echo "*.pyc" >> .gitignore
Now let’s see where we are…
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: .gitignore
new file: functional_tests.py
new file: manage.py
new file: superlists/__init__.py
new file: superlists/settings.py
new file: superlists/urls.py
new file: superlists/wsgi.py
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: .gitignore
Looking good, we’re ready to do our first commit!
$ git add .gitignore
$ git commit
If vi pops up and you have no idea what to do, ask!
Extending Our Functional Test Using the unittest Module
Using a Functional Test to Scope Out a Minimum Viable App
FT = user story, written as comments:
from selenium import webdriver
browser = webdriver.Firefox()
# Edith has heard about a cool new online to-do app. She goes
# to check out its homepage
browser.get('http://localhost:8000')
# She notices the page title and header mention to-do lists
assert 'To-Do' in browser.title
# She is invited to enter a to-do item straight away
# She types "Buy peacock feathers" into a text box (Edith's hobby
# is tying fly-fishing lures)
# When she hits enter, the page updates, and now the page lists
# "1: Buy peacock feathers" as an item in a to-do list
# There is still a text box inviting her to add another item. She
# enters "Use peacock feathers to make a fly" (Edith is very methodical)
# The page updates again, and now shows both items on her list
# Edith wonders whether the site will remember her list. Then she sees
# that the site has generated a unique URL for her -- there is some
# explanatory text to that effect.
# She visits that URL - her to-do list is still there.
# Satisfied, she goes back to sleep
browser.quit()
First, start up the server:
$ python3 manage.py runserver
And then, in another shell, run the tests:
$ python3 functional_tests.py
Traceback (most recent call last):
File "functional_tests.py", line 10, in <module>
assert 'To-Do' in browser.title
AssertionError
-
"expected fail"
The Python Standard Library’s unittest Module
Fixing the unhelpful error message — option 1:
assert 'To-Do' in browser.title, "Browser title was " + browser.title
And we could also use a try/finally
to clean up the old Firefox window.
Better: use unittest
:
from selenium import webdriver
import unittest
class NewVisitorTest(unittest.TestCase): #
def setUp(self): #
self.browser = webdriver.Firefox()
def tearDown(self): #
self.browser.quit()
def test_can_start_a_list_and_retrieve_it_later(self): #
# Edith has heard about a cool new online to-do app. She goes
# to check out its homepage
self.browser.get('http://localhost:8000')
# She notices the page title and header mention to-do lists
self.assertIn('To-Do', self.browser.title) #
self.fail('Finish the test!') #
# She is invited to enter a to-do item straight away
[...rest of comments as before]
if __name__ == '__main__': #
unittest.main(warnings='ignore') #
Make sure I explain all of the little numbers!
Let’s try it!
$ python3 functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 18, in
test_can_start_a_list_and_retrieve_it_later
self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'Welcome to Django'
---------------------------------------------------------------------
Ran 1 test in 1.747s
FAILED (failures=1)
Bonzer!
Implicit waits
[...]
def setUp(self):
self.browser = webdriver.Firefox()
self.browser.implicitly_wait(3)
def tearDown(self):
[...]
Optional: Commit
Do a git status
—that should assure you that the only file that has
changed is functional_tests.py. Then do a git diff
, which shows you the
difference between the last commit and what’s currently on disk. That should
tell you that functional_tests.py has changed quite substantially:
$ git diff
diff --git a/functional_tests.py b/functional_tests.py
index d333591..b0f22dc 100644
--- a/functional_tests.py
+++ b/functional_tests.py
@@ -1,6 +1,45 @@
from selenium import webdriver
+import unittest
-browser = webdriver.Firefox()
-browser.get('http://localhost:8000')
+class NewVisitorTest(unittest.TestCase):
-assert 'Django' in browser.title
+ def setUp(self):
+ self.browser = webdriver.Firefox()
+ self.browser.implicitly_wait(3)
+
+ def tearDown(self):
+ self.browser.quit()
[...]
Now let’s do a:
$ git commit -a
The -a
means “automatically add any changes to tracked files”
When the editor pops up, add a descriptive commit message, like “First FT specced out in comments, and now uses unittest”.
Testing a Simple Home Page with Unit Tests
Let’s start an app for our to-do lists:
Our First Django App, and Our First Unit Test
Projects are made up of apps…
$ python3 manage.py startapp lists
That will create a folder at superlists/lists, next to superlists/superlists, and within it a number of placeholder files for things like models, views, and, of immediate interest to us, tests:
superlists/
├── db.sqlite3
├── functional_tests.py
├── lists
│ ├── admin.py
│ ├── __init__.py
│ ├── migrations
│ │ └── __init__.py
│ ├── models.py
│ ├── tests.py
│ └── views.py
├── manage.py
└── superlists
├── __init__.py
├── __pycache__
├── settings.py
├── urls.py
└── wsgi.py
Unit Tests, and How They Differ from Functional Tests
![]() |
a good place to ask questions, if I don’t explain myself well here! |
Unit Testing in Django
Let’s see how to write a unit test for our home page view. Open up the new file at lists/tests.py, and you’ll see something like this:
from django.test import TestCase
# Create your tests here.
Let’s deliberately create a breaking test and see if we can see it fail.
from django.test import TestCase
class SmokeTest(TestCase):
def test_bad_maths(self):
self.assertEqual(1 + 1, 3)
Now let’s invoke this mysterious Django test runner. As usual, it’s a
manage.py
$ python3 manage.py test lists
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "/workspace/superlists/lists/tests.py", line 6, in test_bad_maths
self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3
---------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
Excellent. The machinery seems to be working. This is a good point for a commit:
$ git status # should show you lists/ is untracked
$ git add lists
$ git diff --staged # will show you the diff that you're about to commit
$ git commit -m"Add app for lists, with deliberately failing unit test"
As no doubt you’ve guessed, the -m
flag lets you pass in a commit message
at the command-line.
Django’s MVC, URLs, and View Functions
-
MVC
-
resolving URLs
-
view functions
Open up lists/tests.py, and change our silly test to something like this:
Unit testing a view
from django.test import TestCase
from django.http import HttpRequest
from lists.views import home_page
class HomePageTest(TestCase):
def test_home_page_returns_correct_html(self):
request = HttpRequest() #
response = home_page(request) #
self.assertTrue(response.content.startswith(b'<html>')) #
self.assertIn(b'<title>To-Do lists</title>', response.content) #
self.assertTrue(response.content.endswith(b'</html>')) #
So, what do you think will happen when we run the tests?
$ python3 manage.py test
ImportError: cannot import name 'home_page'
At Last! We Actually Write Some Application Code!
What next?
from django.shortcuts import render
# Create your views here.
home_page = None
Yeah. Srsly
Unit test / code cycle
response = home_page(request)
TypeError: 'NoneType' object is not callable
-
minimal code:
from django.shortcuts import render
# Create your views here.
def home_page():
pass
-
tests:
TypeError: home_page() takes 0 positional arguments but 1 was given
-
code:
def home_page(request):
pass
-
Tests:
self.assertTrue(response.content.startswith(b'<html>'))
AttributeError: 'NoneType' object has no attribute 'content'
-
Code—we use
django.http.HttpResponse
, as predicted:
from django.http import HttpResponse
# Create your views here.
def home_page(request):
return HttpResponse()
-
Tests again:
self.assertTrue(response.content.startswith(b'<html>'))
AssertionError: False is not true
-
Code again:
def home_page(request):
return HttpResponse('<html>')
-
Tests:
AssertionError: b'<title>To-Do lists</title>' not found in b'<html>'
-
Code:
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title>')
-
Tests—almost there?
self.assertTrue(response.content.endswith(b'</html>'))
AssertionError: False is not true
-
Come on, one last effort:
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title></html>')
-
Surely?
$ python3 manage.py test
Creating test database for alias 'default'...
.
---------------------------------------------------------------------
Ran 1 test in 0.003s
OK
Destroying test database for alias 'default'...
Yes! Now, let’s run our functional tests. Don’t forget to spin up the dev server again, if it’s not still running. It feels like the final heat of the race here, surely this is it … could it be?
$ python3 functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 19, in
test_can_start_a_list_and_retrieve_it_later
self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'Welcome to Django'
---------------------------------------------------------------------
Ran 1 test in 1.747s
FAILED (failures=1)
Nope!
Urls.py
urlpatterns = patterns('',
# Examples:
url(r'^$', 'lists.views.home_page', name='home'),
[...]
Back to FTS:
$ python3 functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 20, in
test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
---------------------------------------------------------------------
Ran 1 test in 1.609s
FAILED (failures=1)
Yay! expected failure.
$ git diff # should show urls.py, tests.py, views.py
$ git commit -am"Basic view now returns minimal HTML"
Not bad—we covered:
-
Starting a Django app
-
The Django unit test runner
-
The difference between FTs and unit tests
-
Django URL resolving and urls.py
-
Django view functions, request and response objects
-
And returning basic HTML
What Are We Doing with All These Tests?

Topics to discuss:
-
"Bucket from a well" metaphor
-
TDD as a discipline — kata
-
the value of ridiculously small/simple tests?
Now, back to our onions.
Using Selenium to Test User Interactions
Where were we at the end of the last chapter? Let’s rerun the test and find out:
$ python3 functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 20, in
test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
---------------------------------------------------------------------
Ran 1 test in 1.609s
FAILED (failures=1)
Did you try it, and get an error saying Problem loading page or
Unable to connect? So did I. It’s because we forgot to spin up the dev
server first using manage.py runserver
. Do that, and you’ll get the failure
message we’re after.
“Finish the test”, it says, so let’s do just that! Open up functional_tests.py and we’ll extend our FT:
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest
class NewVisitorTest(unittest.TestCase):
def setUp(self):
self.browser = webdriver.Firefox()
self.browser.implicitly_wait(3)
def tearDown(self):
self.browser.quit()
def test_can_start_a_list_and_retrieve_it_later(self):
# Edith has heard about a cool new online to-do app. She goes
# to check out its homepage
self.browser.get('http://localhost:8000')
# She notices the page title and header mention to-do lists
self.assertIn('To-Do', self.browser.title)
header_text = self.browser.find_element_by_tag_name('h1').text
self.assertIn('To-Do', header_text)
# She is invited to enter a to-do item straight away
inputbox = self.browser.find_element_by_id('id_new_item')
self.assertEqual(
inputbox.get_attribute('placeholder'),
'Enter a to-do item'
)
# She types "Buy peacock feathers" into a text box (Edith's hobby
# is tying fly-fishing lures)
inputbox.send_keys('Buy peacock feathers')
# When she hits enter, the page updates, and now the page lists
# "1: Buy peacock feathers" as an item in a to-do list table
inputbox.send_keys(Keys.ENTER)
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertTrue(
any(row.text == '1: Buy peacock feathers' for row in rows)
)
# There is still a text box inviting her to add another item. She
# enters "Use peacock feathers to make a fly" (Edith is very
# methodical)
self.fail('Finish the test!')
# The page updates again, and now shows both items on her list
[...]
Ask about:
* find_element_by_tag_name
, find_element_by_id
,
* find_element
s
_by_tag_name
(notice the extra s
)
* send_keys
,
* Keys
class
* any
function + generator expressions
![]() |
Watch out for the difference between the Selenium find_element_by...
and find_elements_by... functions. One returns an element, and raises
an exception if it can’t find it, whereas the other returns a list, which
may be empty. |
Let’s see how it gets on:
$ python3 functional_tests.py
[...]
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"tag name","selector":"h1"}' ; Stacktrace: [...]
Big changes to a functional test are usually a good thing to commit on their own
$ git diff # should show changes to functional_tests.py
$ git commit -am "Functional test now checks we can input a to-do item"
The “Don’t Test Constants” Rule, and Templates to the Rescue
In other words, if you have some code that says:
wibble = 3
There’s not much point in a test that says:
from myprogram import wibble
assert wibble == 3
Refactoring to Use a Template
Objective: refactor view to return same HTML
Start with test run:
$ python3 manage.py test
[...]
OK
<html>
<title>To-Do lists</title>
</html>
Mmmh, syntax-highlighted … much nicer! Now to change our view function:
from django.shortcuts import render
def home_page(request):
return render(request, 'home.html')
Let’s see if it works:
$ python3 manage.py test
[...]
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "/workspace/superlists/lists/tests.py", line 17, in
test_home_page_returns_correct_html
response = home_page(request)
File "/workspace/superlists/lists/views.py", line 5, in home_page
return render(request, 'home.html')
File "/usr/local/lib/python3.3/dist-packages/django/shortcuts.py", line 48,
in render
return HttpResponse(loader.render_to_string(*args, **kwargs),
File "/usr/local/lib/python3.3/dist-packages/django/template/loader.py", line
170, in render_to_string
t = get_template(template_name, dirs)
File "/usr/local/lib/python3.3/dist-packages/django/template/loader.py", line
144, in get_template
template, origin = find_template(template_name, dirs)
File "/usr/local/lib/python3.3/dist-packages/django/template/loader.py", line
136, in find_template
raise TemplateDoesNotExist(name)
django.template.base.TemplateDoesNotExist: home.html
---------------------------------------------------------------------
Ran 2 tests in 0.004s
Another chance to analyse a traceback:
![]() | We start with the error: it can’t find the template. |
![]() | Then we double-check what test is failing: sure enough, it’s our test of the view HTML. |
![]() |
Then we find the line in our tests that caused the failure: it’s when
we call the home_page function.
|
![]() |
Finally, we look for the part of our own application code that caused the
failure: it’s when we try and call render .
|
So why can’t Django find the template? It’s right where it’s supposed to be, in the lists/templates folder.
-
Need to add lists app to settings.py:
# Application definition
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'lists',
)
Does that do the trick?
$ python3 manage.py test
[...]
self.assertTrue(response.content.endswith(b'</html>'))
AssertionError: False is not true
Darn, not quite.
![]() |
Depending on whether your text editor insists on adding newlines to the end of files, you may not even see this error. If so, you can safely ignore the next bit, and skip straight to where you can see the listing says OK. |
self.assertTrue(response.content.strip().endswith(b'</html>'))
hooray!
$ python3 manage.py test
[...]
OK
Now we can change the tests so that they’re no longer testing constants
from django.template.loader import render_to_string
[...]
def test_home_page_returns_correct_html(self):
request = HttpRequest()
response = home_page(request)
expected_html = render_to_string('home.html')
self.assertEqual(response.content.decode(), expected_html)
-
ask about .decode() if we haven’t spoken about Python 3 and strings vs bytes
On refactoring
Am I recommending that you actually work this way? No. I’m recommending that you be able to work this way.
— Kent Beck
![]() |
When refactoring, work on either the code or the tests, but not both at once. |

It’s a good idea to do a commit after any refactoring:
$ git status # see tests.py, views.py, settings.py, + new templates folder
$ git add . # will also add the untracked templates folder
$ git diff --staged # review the changes we're about to commit
$ git commit -m"Refactor home page view to use a template"
A Little More of Our Front Page
In the meantime, our functional test is still failing. Let’s now make an actual code change to get it passing.
<html>
<head>
<title>To-Do lists</title>
</head>
<body>
<h1>Your To-Do list</h1>
</body>
</html>
Let’s see if our functional test likes it a little better:
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_new_item"}' ; Stacktrace: [...]
OK…
[...]
<h1>Your To-Do list</h1>
<input id="id_new_item" />
</body>
[...]
And now?
AssertionError: '' != 'Enter a to-do item'
We add our placeholder text…
<input id="id_new_item" placeholder="Enter a to-do item" />
Which gives:
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_list_table"}' ; Stacktrace: [...]
So we can go ahead and put the table onto the page. At this stage it’ll just be empty…
<input id="id_new_item" placeholder="Enter a to-do item" />
<table id="id_list_table">
</table>
</body>
Now what does the FT say?
File "functional_tests.py", line 42, in
test_can_start_a_list_and_retrieve_it_later
any(row.text == '1: Buy peacock feathers' for row in rows)
AssertionError: False is not true
That any
function!
self.assertTrue(
any(row.text == '1: Buy peacock feathers' for row in rows),
"New to-do item did not appear in table"
)
If you run the FT again, you should see our message:
AssertionError: False is not true : New to-do item did not appear in table
But now, to get this to pass, we will need to actually process the user’s form submission. And that’s a topic for the next chapter.
For now let’s do a commit:
$ git diff
$ git commit -am"Front page HTML now generated from a template"
Thanks to a bit of refactoring, we’ve got our view set up to render a template, we’ve stopped testing constants, and we’re now well placed to start processing user input.
Recap: The TDD Process
We’ve now seen all the main aspects of the TDD process, in practice:
-
Functional tests
-
Unit tests
-
The unit-test/code cycle
-
Refactoring
What is the overall TDD process? See [simple-TDD-diagram].


Saving User Input
Wiring Up Our Form to Send a POST Request
To get our browser to send a POST request, we give the <input>
element a
name=
attribute, wrap it in a <form>
tag with method="POST"
, and the
browser will take care of sending the POST request to the server for us. Let’s
adjust our template at lists/templates/home.html:
<h1>Your To-Do list</h1>
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
</form>
<table id="id_list_table">
Now, running our FTs gives us a slightly cryptic, unexpected error:
$ python3 functional_tests.py
[...]
Traceback (most recent call last):
File "functional_tests.py", line 39, in
test_can_start_a_list_and_retrieve_it_later
table = self.browser.find_element_by_id('id_list_table')
[...]
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_list_table"}' ; Stacktrace [...]
When a functional test fails with an unexpected failure, there are several things we can do to debug them:
-
Add
print
statements, to show, eg, what the current page text is. -
Improve the error message to show more info about the current state.
-
Manually visit the site yourself.
-
Use
time.sleep
to pause the test during execution.
# When she hits enter, the page updates, and now the page lists
# "1: Buy peacock feathers" as an item in a to-do list table
inputbox.send_keys(Keys.ENTER)
import time
time.sleep(10)
table = self.browser.find_element_by_id('id_list_table')

![]() |
check out Ross Anderson’s "Security Engineering" |
csrf token magic:
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
{% csrf_token %}
</form>
AssertionError: False is not true : New to-do item did not appear in table
We can remove the time.sleep
now though:
# "1: Buy peacock feathers" as an item in a to-do list table
inputbox.send_keys(Keys.ENTER)
table = self.browser.find_element_by_id('id_list_table')
Processing a POST Request on the Server
def test_home_page_returns_correct_html(self):
[...]
def test_home_page_can_save_a_POST_request(self):
request = HttpRequest()
request.method = 'POST'
request.POST['item_text'] = 'A new list item'
response = home_page(request)
self.assertIn('A new list item', response.content.decode())
-
Ask about: line spacing in tests
$ python3 manage.py test
[...]
AssertionError: 'A new list item' not found in '<html> [...]
In typical TDD style, we start with a deliberately silly return value:
from django.http import HttpResponse
from django.shortcuts import render
def home_page(request):
if request.method == 'POST':
return HttpResponse(request.POST['item_text'])
return render(request, 'home.html')
That gets our unit tests passing, but it’s not really what we want. What we really want to do is add the POST submission to the table in the home page template.
Passing Python Variables to Be Rendered in the Template
<body>
<h1>Your To-Do list</h1>
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
{% csrf_token %}
</form>
<table id="id_list_table">
<tr><td>{{ new_item_text }}</td></tr>
</table>
</body>
Use template variable in test:
self.assertIn('A new list item', response.content.decode())
expected_html = render_to_string(
'home.html',
{'new_item_text': 'A new list item'}
)
self.assertEqual(response.content.decode(), expected_html)
Next:
self.assertEqual(response.content.decode(), expected_html)
AssertionError: 'A new list item' != '<html>\n <head>\n [...]
use in view:
def home_page(request):
return render(request, 'home.html', {
'new_item_text': request.POST['item_text'],
})
Running the unit tests again:
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
[...]
'new_item_text': request.POST['item_text'],
KeyError: 'item_text'
An "unexpected failure"… in a different test! This is the whole point of having tests.
def home_page(request):
return render(request, 'home.html', {
'new_item_text': request.POST.get('item_text', ''),
})
-
confused about request.POST.get? ask!
The unit tests should now pass. Let’s see what the functional tests say:
AssertionError: False is not true : New to-do item did not appear in table
Hmm, not a wonderfully helpful error. Let’s use another of our FT debugging techniques: improving the error message. This is probably the most constructive technique, because those improved error messages stay around to help debug any future errors:
self.assertTrue(
any(row.text == '1: Buy peacock feathers' for row in rows),
"New to-do item did not appear in table -- its text was:\n%s" % (
table.text,
)
)
That gives us a more helpful error message:
AssertionError: False is not true : New to-do item did not appear in table --
its text was:
Buy peacock feathers
You know what could be even better than that?
self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
-
Discussion point: feeling clever about your code is a code smell!
Much better.
self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
AssertionError: '1: Buy peacock feathers' not found in ['Buy peacock feathers']
<tr><td>1: {{ new_item_text }}</td></tr>
![]() |
ask about Red/Green/Refactor, and Triangulation |
Now we get to the self.fail('Finish the test!')
. If we extend our FT to
check for adding a second item to the table (copy and paste is our friend), we
begin to see that our first cut solution really isn’t going to, um, cut it:
# There is still a text box inviting her to add another item. She
# enters "Use peacock feathers to make a fly" (Edith is very
# methodical)
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Use peacock feathers to make a fly')
inputbox.send_keys(Keys.ENTER)
# The page updates again, and now shows both items on her list
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
self.assertIn(
'2: Use peacock feathers to make a fly' ,
[row.text for row in rows]
)
# Edith wonders whether the site will remember her list. Then she sees
# that the site has generated a unique URL for her -- there is some
# explanatory text to that effect.
self.fail('Finish the test!')
# She visits that URL - her to-do list is still there.
Sure enough, the functional tests return an error:
AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock
feathers to make a fly']
Three Strikes and Refactor
Before we go further—we’ve got a bad “code smell” in this FT.
$ git diff
# should show changes to functional_tests.py, home.html,
# tests.py and views.py
$ git commit -a
Helper:
def tearDown(self):
self.browser.quit()
def check_for_row_in_list_table(self, row_text):
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn(row_text, [row.text for row in rows])
def test_can_start_a_list_and_retrieve_it_later(self):
[...]
Let’s use it in the FT:
# When she hits enter, the page updates, and now the page lists
# "1: Buy peacock feathers" as an item in a to-do list table
inputbox.send_keys(Keys.ENTER)
self.check_for_row_in_list_table('1: Buy peacock feathers')
# There is still a text box inviting her to add another item. She
# enters "Use peacock feathers to make a fly" (Edith is very
# methodical)
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Use peacock feathers to make a fly')
inputbox.send_keys(Keys.ENTER)
# The page updates again, and now shows both items on her list
self.check_for_row_in_list_table('1: Buy peacock feathers')
self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')
# Edith wonders whether the site will remember her list. Then she sees
[...]
We run the FT again to check that it still behaves in the same way…
AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock
feathers to make a fly']
Good. Now we can commit the FT refactor as its own small, atomic change:
$ git diff # check the changes to functional_tests.py
$ git commit -a
And back to work.
The Django ORM and Our First Model
Let’s create a new class in lists/tests.py:
from lists.models import Item
[...]
class ItemModelTest(TestCase):
def test_saving_and_retrieving_items(self):
first_item = Item()
first_item.text = 'The first (ever) list item'
first_item.save()
second_item = Item()
second_item.text = 'Item the second'
second_item.save()
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, 'The first (ever) list item')
self.assertEqual(second_saved_item.text, 'Item the second')
Django’s ORM has many other helpful and intuitive features; this might be a good time to skim through the Django tutorial, which has an excellent intro to them.
![]() |
I’ve written this unit test in a very verbose style, as a way of introducing the Django ORM. You can actually write a much shorter test for a model class, which we’ll see later on, in [simple-form-chapter]. |
![]() |
Ask about integrated vs pure unit tests, and "the database is Hot Lava!" |
ImportError: cannot import name 'Item'
from django.db import models
class Item(object):
pass
That gets our test as far as:
first_item.save()
AttributeError: 'Item' object has no attribute 'save'
To give our Item
class a save
method, and to make it into a real Django
model, we make it inherit from the Model
class:
from django.db import models
class Item(models.Model):
pass
Our First Database Migration
The next thing that happens is a database error:
django.db.utils.OperationalError: no such table: lists_item
![]() |
Ask about "migrations are a VCS for your database" |
For now all we need to know is how to build our first database migration,
which we do using the makemigrations
command:
$ python3 manage.py makemigrations
Migrations for 'lists':
0001_initial.py:
- Create model Item
$ ls lists/migrations
0001_initial.py __init__.py __pycache__
If you’re curious, you can go and take a look in the migrations file, and you’ll see it’s a representation of our additions to models.py.
In the meantime, we should find our tests get a little further.
The Test Gets Surprisingly Far
The test actually gets surprisingly far:
$ python3 manage.py test lists
[...]
self.assertEqual(first_saved_item.text, 'The first (ever) list item')
AttributeError: 'Item' object has no attribute 'text'
-
Discussion: how did it get this far?
Next:
class Item(models.Model):
text = models.TextField()
You can read more on field types in the Django tutorial and in the documentation.
A New Field Means a New Migration
Running the tests gives us another database error:
django.db.utils.OperationalError: no such column: lists_item.text
It’s because we’ve added another new field to our database, which means we need to create another migration. Nice of our tests to let us know!
Let’s try it:
$ python3 manage.py makemigrations
You are trying to add a non-nullable field 'text' to item without a default;
we can't do that (the database needs something to populate existing rows).
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows)
2) Quit, and let me add a default in models.py
Select an option:2
Ah. It won’t let us add the column without a default value. Let’s pick option 2 and set a default in models.py. I think you’ll find the syntax reasonably self-explanatory:
class Item(models.Model):
text = models.TextField(default='')
And now the migration should complete:
$ python3 manage.py makemigrations
Migrations for 'lists':
0002_item_text.py:
- Add field text to item
So, two new lines in models.py, two database migrations, and as a result,
the .text
attribute on our model objects is now
recognised as a special attribute, so it does get saved to the database, and
the tests pass…
$ python3 manage.py test lists
[...]
Ran 4 tests in 0.010s
OK
So let’s do a commit for our first ever model!
$ git status # see tests.py, models.py, and 2 untracked migrations
$ git diff # review changes to tests.py and models.py
$ git add lists
$ git commit -m"Model for list Items and associated migration"
Saving the POST to the Database
def test_home_page_can_save_a_POST_request(self):
request = HttpRequest()
request.method = 'POST'
request.POST['item_text'] = 'A new list item'
response = home_page(request)
self.assertEqual(Item.objects.count(), 1) #
new_item = Item.objects.first() #
self.assertEqual(new_item.text, 'A new list item') #
self.assertIn('A new list item', response.content.decode())
expected_html = render_to_string(
'home.html',
{'new_item_text': 'A new list item'}
)
self.assertEqual(response.content.decode(), expected_html)
A reminder on a scratch piece of paper:
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
Let’s adjust our view:
from django.shortcuts import render
from lists.models import Item
def home_page(request):
item = Item()
item.text = request.POST.get('item_text', '')
item.save()
return render(request, 'home.html', {
'new_item_text': request.POST.get('item_text', ''),
})
return render(request, 'home.html', {
'new_item_text': item.text
})
Let’s have a little look at our scratchpad. I’ve added a couple of the other things that are on our mind:
class HomePageTest(TestCase):
[...]
def test_home_page_only_saves_items_when_necessary(self):
request = HttpRequest()
home_page(request)
self.assertEqual(Item.objects.count(), 0)
That gives us a 1 != 0
failure.
def home_page(request):
if request.method == 'POST':
new_item_text = request.POST['item_text'] #
Item.objects.create(text=new_item_text) #
else:
new_item_text = '' #
return render(request, 'home.html', {
'new_item_text': new_item_text, #
})
Ran 5 tests in 0.010s
OK
Redirect After a POST
def test_home_page_can_save_a_POST_request(self):
request = HttpRequest()
request.method = 'POST'
request.POST['item_text'] = 'A new list item'
response = home_page(request)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/')
That gives us the error 200 != 302
. We can now tidy up our view
substantially:
from django.shortcuts import redirect, render
from lists.models import Item
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/')
return render(request, 'home.html')
And the tests should now pass:
Ran 5 tests in 0.010s
OK
Rendering Items in the Template
Back to our to-do list:
class HomePageTest(TestCase):
[...]
def test_home_page_displays_all_list_items(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
request = HttpRequest()
response = home_page(request)
self.assertIn('itemey 1', response.content.decode())
self.assertIn('itemey 2', response.content.decode())
That fails as expected:
AssertionError: 'itemey 1' not found in '<html>\n <head>\n [...]
<table id="id_list_table">
{% for item in items %}
<tr><td>1: {{ item.text }}</td></tr>
{% endfor %}
</table>
Just changing the template doesn’t get our tests to pass; we need to actually pass the items to it from our home page view:
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/')
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
That does get the unit tests to pass … moment of truth, will the functional test pass?
$ python3 functional_tests.py
[...]
AssertionError: 'To-Do' not found in 'OperationalError at /'
Oops, apparently not. Let’s use another functional test debugging technique, and it’s one of the most straightforward: manually visiting the site! Open up http://localhost:8000 in your web browser, and you’ll see a Django debug page saying "no such table: lists_item", as in [operationalerror].

Creating Our Production Database with migrate
[...]
# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
$ python3 manage.py migrate
Operations to perform:
Synchronize unmigrated apps: contenttypes, sessions, admin, auth
Apply all migrations: lists
Synchronizing apps without migrations:
Creating tables...
Creating table django_admin_log
Creating table auth_permission
Creating table auth_group_permissions
Creating table auth_group
Creating table auth_user_groups
Creating table auth_user_user_permissions
Creating table auth_user
Creating table django_content_type
Creating table django_session
Installing custom SQL...
Installing indexes...
Running migrations:
Applying lists.0001_initial... OK
Applying lists.0002_item_text... OK
Now we can refresh the page on localhost, see that our error is gone, and try running the functional tests again:[1]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers', '1: Use peacock feathers to make a fly']
So close! We just need to get our list numbering right. Another awesome
Django template tag, forloop.counter
, will help here:
{% for item in items %}
<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
{% endfor %}
If you try it again, you should now see the FT get to the end:
self.fail('Finish the test!')
AssertionError: Finish the test!
Oh dear. It looks like previous runs of the test are leaving stuff lying around in our database.
1: Buy peacock feathers
2: Use peacock feathers to make a fly
3: Buy peacock feathers
4: Use peacock feathers to make a fly
5: Buy peacock feathers
6: Use peacock feathers to make a fly
Grrr. We’re so close!
$ rm db.sqlite3
$ python3 manage.py migrate --noinput
That’s more or less working. Commit for now.
$ git add lists
$ git commit -m"Redirect after POST, and show all items in template"
![]() |
You might find it useful to add markers for the end of each chapter, like
git tag end-of-chapter-05 . |
Where are we?
-
We’ve got a form set up to add new items to the list using POST.
-
We’ve set up a simple model in the database to save list items.
-
We’ve used at least three different FT debugging techniques.
But we’ve got a couple of items on our own to-do list, namely getting the FT to clean up after itself, and perhaps more critically, adding support for more than one list.
I mean, we could ship the site as it is, but people might find it strange that the entire human population has to share a single to-do list. I suppose it might get people to stop and think about how connected we all are to one another, how we all share a common destiny here on Spaceship Earth, and how we must all work together to solve the global problems that we face.
But in practical terms, the site wouldn’t be very useful.
Ah well.
Getting to the Minimum Viable Site
-
In which we use incremental, step-by-step refactoring to get to a better app. Testing Goat, not Refactoring Cat!
Ensuring Test Isolation in Functional Tests
We ended the last chapter with a classic testing problem: how to ensure isolation between tests.
$ mkdir functional_tests
$ touch functional_tests/__init__.py
Then move the FT file:
$ git mv functional_tests.py functional_tests/tests.py
$ git status # shows the rename to functional_tests/tests.py and __init__.py
At this point your directory tree should look like this:
.
├── db.sqlite3
├── functional_tests
│ ├── __init__.py
│ └── tests.py
├── lists
│ ├── admin.py
│ ├── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── 0002_item_text.py
│ │ ├── __init__.py
│ │ └── __pycache__
│ ├── models.py
│ ├── __pycache__
│ ├── templates
│ │ └── home.html
│ ├── tests.py
│ └── views.py
├── manage.py
└── superlists
├── __init__.py
├── __pycache__
├── settings.py
├── urls.py
└── wsgi.py
functional_tests.py is gone, and has turned into functional_tests/tests.py.
Now, whenever we want to run our functional tests, instead of running python3
functional_tests.py
, we will use python3 manage.py test functional_tests
.
![]() |
discuss why keep FTs separate from other apps? |
Now let’s edit functional_tests/tests.py and change our NewVisitorTest
class to make it use LiveServerTestCase
:
from django.test import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
class NewVisitorTest(LiveServerTestCase):
def setUp(self):
[...]
Next,
instead of hard-coding the visit to localhost port 8000, LiveServerTestCase
gives us an attribute called live_server_url
:
def test_can_start_a_list_and_retrieve_it_later(self):
# Edith has heard about a cool new online to-do app. She goes
# to check out its homepage
self.browser.get(self.live_server_url)
Now we are able to run our Functional tests using the Django test runner, by
telling it to run just the tests for our new functional_tests
app:
$ python3 manage.py test functional_tests
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later
(functional_tests.tests.NewVisitorTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "/workspace/superlists/functional_tests/tests.py", line 61, in
test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
---------------------------------------------------------------------
Ran 1 test in 6.378s
FAILED (failures=1)
Destroying test database for alias 'default'...
Woot!
$ git status # functional_tests.py renamed + modified, new __init__.py
$ git add functional_tests
$ git diff --staged -M
$ git commit # msg eg "make functional_tests an app, use LiveServerTestCase"
The -M
flag on the git diff
is a useful one. It means "detect moves", so it
will notice that functional_tests.py and functional_tests/tests.py are the
same file, and show you a more sensible diff (try it without the flag!).
Running Just the Unit Tests
Now if we run manage.py test
, Django will run both the functional and the
unit tests:
$ python3 manage.py test
Creating test database for alias 'default'...
.......F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later
[...]
AssertionError: Finish the test!
---------------------------------------------------------------------
Ran 8 tests in 3.132s
FAILED (failures=1)
Destroying test database for alias 'default'...
In order to run just the unit tests, we can specify that we want to
only run the tests for the lists
app:
$ python3 manage.py test lists
Creating test database for alias 'default'...
.......
---------------------------------------------------------------------
Ran 7 tests in 0.009s
OK
Destroying test database for alias 'default'...
Currently the FT says this:
# Edith wonders whether the site will remember her list. Then she sees
# that the site has generate a unique URL for her -- there is some
# explanatory text to that effect.
self.fail('Finish the test!')
# She visits that URL - her to-do list is still there.
# Satisfied, she goes back to sleep
Let’s think about this a bit more.
Small Design When Necessary
-
Big Design up-front
-
Minimum viable app
-
YAGNI
-
REST (ish)
Each list can have its own URL, like
/lists/<list identifier>/
To create a brand new list, we’ll have a special URL that accepts POST requests:
/lists/new
To add a new item to an existing list, we’ll have a separate URL, to which we can send POST requests:
/lists/<list identifier>/add_item
In summary, our scratchpad for this chapter looks something like this:
Implementing the New Design Using TDD
inputbox.send_keys('Buy peacock feathers')
# When she hits enter, she is taken to a new URL,
# and now the page lists "1: Buy peacock feathers" as an item in a
# to-do list table
inputbox.send_keys(Keys.ENTER)
edith_list_url = self.browser.current_url
self.assertRegex(edith_list_url, '/lists/.+') #
self.check_for_row_in_list_table('1: Buy peacock feathers')
# There is still a text box inviting her to add another item. She
[...]
Delete everything from the comments just before the self.fail
(they say
"Edith wonders whether the site will remember her list …") and replace
them with a new ending to our FT:
[...]
# The page updates again, and now shows both items on her list
self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')
self.check_for_row_in_list_table('1: Buy peacock feathers')
# Now a new user, Francis, comes along to the site.
## We use a new browser session to make sure that no information
## of Edith's is coming through from cookies etc #
self.browser.quit()
self.browser = webdriver.Firefox()
# Francis visits the home page. There is no sign of Edith's
# list
self.browser.get(self.live_server_url)
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertNotIn('make a fly', page_text)
# Francis starts a new list by entering a new item. He
# is less interesting than Edith...
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Buy milk')
inputbox.send_keys(Keys.ENTER)
# Francis gets his own unique URL
francis_list_url = self.browser.current_url
self.assertRegex(francis_list_url, '/lists/.+')
self.assertNotEqual(francis_list_url, edith_list_url)
# Again, there is no trace of Edith's list
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertIn('Buy milk', page_text)
# Satisfied, they both go back to sleep
*Ask about: double-hashes (##
)
Gives
AssertionError: Regex didn't match: '/lists/.+' not found in
'http://localhost:8081/'
$ git commit -a
Iterating Towards the New Design
The URL comes from the redirect after POST. In lists/tests.py, find
test_home_page_redirects_after_POST
, and change the expected redirect
location:
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
![]() |
Another way of thinking about it is as a problem-solving technique: our new URL design is currently not implemented, so it works for 0 items. Ultimately, we want to solve for n items, but solving for 1 item is a good step along the way. |
Running the unit tests gives us an expected fail:
$ python3 manage.py test lists
[...]
AssertionError: '/' != '/lists/the-only-list-in-the-world/'
We can go adjust our home_page
view in lists/views.py:
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
Of course that will now totally break the functional test.
self.check_for_row_in_list_table('1: Buy peacock feathers')
[...]
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_list_table"}' ; Stacktrace:
So, let’s build a special URL for our one and only list.
Testing Views, Templates, and URLs Together with the Django Test Client
A New Test Class
class ListViewTest(TestCase):
def test_displays_all_items(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
response = self.client.get('/lists/the-only-list-in-the-world/') #
self.assertContains(response, 'itemey 1') #
self.assertContains(response, 'itemey 2') #
Let’s try running the test now:
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404
A New URL
Our singleton list URL doesn’t exist yet. We fix that in superlists/urls.py.
![]() |
Watch out for trailing slashes in URLs, both here in the tests and in urls.py—They’re a common source of bugs. |
urlpatterns = patterns('',
url(r'^$', 'lists.views.home_page', name='home'),
url(r'^lists/the-only-list-in-the-world/$', 'lists.views.view_list',
name='view_list'
),
# url(r'^admin/', include(admin.site.urls)),
)
Running the tests again, we get:
AttributeError: 'module' object has no attribute 'view_list'
[...]
django.core.exceptions.ViewDoesNotExist: Could not import
lists.views.view_list. View does not exist in module lists.views.
A New View Function
Nicely self-explanatory. Let’s create a dummy view function in lists/views.py:
def view_list(request):
pass
Now we get:
ValueError: The view lists.views.view_list didn't return an HttpResponse
object. It returned None instead.
Let’s copy the two last lines from the home_page
view and see if they’ll do
the trick:
def view_list(request):
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
Rerun the tests and they should pass:
Ran 8 tests in 0.016s
OK
And the FTs should get a little further on:
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']
Green? Refactor
Time for a little tidying up.
$ grep -E "class|def" lists/tests.py
class HomePageTest(TestCase):
def test_root_url_resolves_to_home_page_view(self):
def test_home_page_returns_correct_html(self):
def test_home_page_displays_all_list_items(self):
def test_home_page_can_save_a_POST_request(self):
def test_home_page_redirects_after_POST(self):
def test_home_page_only_saves_items_when_necessary(self):
class ListViewTest(TestCase):
def test_displays_all_items(self):
class ItemModelTest(TestCase):
def test_saving_and_retrieving_items(self):
We can definitely delete the test_home_page_displays_all_list_items
method,
it’s no longer needed. If you run manage.py test lists
now, it should say
it ran 7 tests instead of 8:
Ran 7 tests in 0.016s
OK
Next, we don’t actually need the home page to display all list items any more; it should just show a single input box inviting you to start a new list.
A Separate Template for Viewing Lists
class ListViewTest(TestCase):
def test_uses_list_template(self):
response = self.client.get('/lists/the-only-list-in-the-world/')
self.assertTemplateUsed(response, 'list.html')
def test_displays_all_items(self):
[...]
Let’s see what it says:
AssertionError: False is not true : Template 'list.html' was not a template
used to render the response. Actual template(s) used: home.html
Great! Let’s change the view:
def view_list(request):
items = Item.objects.all()
return render(request, 'list.html', {'items': items})
But, obviously, that template doesn’t exist yet. If we run the unit tests, we get:
django.template.base.TemplateDoesNotExist: list.html
Let’s create a new file at lists/templates/list.html:
$ touch lists/templates/list.html
A blank template, which gives us this error—good to know the tests are there to make sure we fill it in:
AssertionError: False is not true : Couldn't find 'itemey 1' in response
$ cp lists/templates/home.html lists/templates/list.html
<body>
<h1>Start a new To-Do list</h1>
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
{% csrf_token %}
</form>
</body>
<h1>Your To-Do list</h1>
We re-run the unit tests to check that hasn’t broken anything… Good…
There’s actually no need to pass all the items to the home.html template in
our home_page
view, so we can simplify that:
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
return render(request, 'home.html')
Rerun the unit tests; they still pass. Let’s run the functional tests:
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']
The action=
attribute…
<form method="POST" action="/">
And try running the FT again:
self.assertNotEqual(francis_list_url, edith_list_url)
AssertionError: 'http://localhost:8081/lists/the-only-list-in-the-world/' ==
'http://localhost:8081/lists/the-only-list-in-the-world/'
Hooray! We’re back to where we were earlier, which means our refactoring is complete—we now have a unique URL for our one list.
$ git status # should show 4 changed files and 1 new file, list.html
$ git add lists/templates/list.html
$ git diff # should show we've simplified home.html,
# moved one test to a new class in lists/tests.py added a new view
# in views.py, and simplified home_page and made one addition to
# urls.py
$ git commit -a # add a message summarising the above, maybe something like
# "new URL, view and template to display lists"
Another URL and View for Adding List Items
Where are we with our own to-do list?
Let’s have a new URL for adding new list items. If nothing else, it’ll simplify the home page view.
A Test Class for New List Creation
Open up lists/tests.py, and move the
test_home_page_can_save_a_POST_request
and
test_home_page_redirects_after_POST
methods into a new class, then change
their names:
![]() |
if you didn’t do the advanced exercise in the last section, you’ll only have one method here. That’s fine. |
class NewListTest(TestCase):
def test_saving_a_POST_request(self):
request = HttpRequest()
request.method = 'POST'
[...]
def test_redirects_after_POST(self):
[...]
Now let’s use the Django test client:
class NewListTest(TestCase):
def test_saving_a_POST_request(self):
self.client.post(
'/lists/new',
data={'item_text': 'A new list item'}
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
def test_redirects_after_POST(self):
response = self.client.post(
'/lists/new',
data={'item_text': 'A new list item'}
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
This is another place to pay attention to trailing slashes, incidentally. It’s
/new
, with no trailing slash. The convention I’m using is that URLs without
a trailing slash are "action" URLs which modify the database.
Try running that:
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
[...]
self.assertEqual(response.status_code, 302)
AssertionError: 404 != 302
A URL and View for New List Creation
Let’s build our new URL now:
urlpatterns = patterns('',
url(r'^$', 'lists.views.home_page', name='home'),
url(r'^lists/the-only-list-in-the-world/$', 'lists.views.view_list',
name='view_list'
),
url(r'^lists/new$', 'lists.views.new_list', name='new_list'),
# url(r'^admin/', include(admin.site.urls)),
)
Next we get a ViewDoesNotExist
, so let’s fix that, in lists/views.py:
def new_list(request):
pass
def new_list(request):
return redirect('/lists/the-only-list-in-the-world/')
That gives:
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
[...]
AssertionError: 'http://testserver/lists/the-only-list-in-the-world/' !=
'/lists/the-only-list-in-the-world/'
Let’s start with the first failure, because it’s reasonably straightforward. We
borrow another line from home_page
:
def new_list(request):
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
And that takes us down to just the second, unexpected failure:
self.assertEqual(response['location'],
'/lists/the-only-list-in-the-world/')
AssertionError: 'http://testserver/lists/the-only-list-in-the-world/' !=
'/lists/the-only-list-in-the-world/'
Let’s use another of Django’s test helper functions instead of our two-step check for the redirect:
def test_redirects_after_POST(self):
response = self.client.post(
'/lists/new',
data={'item_text': 'A new list item'}
)
self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
That now passes:
Ran 8 tests in 0.030s
OK
Removing Now-Redundant Code and Tests
We’re looking good. Since our new views are now doing most of the work that
home_page
used to do, we should be able to massively simplify it. Can we
remove the whole if request.method == 'POST'
section, for example?
def home_page(request):
return render(request, 'home.html')
Yep!
OK
And while we’re at it, we can remove the now-redundant
test_home_page_only_saves_
items_when_necessary
test too!
Doesn’t that feel good? The view functions are looking much simpler. We rerun the tests to make sure…
Ran 7 tests in 0.016s
OK
Pointing Our Forms at the New URL
Finally, let’s wire up our two forms to use this new URL. In both home.html and lists.html:
<form method="POST" action="/lists/new">
And we re-run our FTs to make sure everything still works…
AssertionError: 'http://localhost:8081/lists/the-only-list-in-the-world/' ==
'http://localhost:8081/lists/the-only-list-in-the-world/'
$ git status # 5 changed files
$ git diff # URLs for forms x2, moved code in views + tests, new URL
$ git commit -a
And we can cross out an item on the to-do list:
Adjusting Our Models
Just for fun, a diff output instead of a plain code listing:
@@ -3,7 +3,7 @@ from django.http import HttpRequest
from django.template.loader import render_to_string
from django.test import TestCase
-from lists.models import Item
+from lists.models import Item, List
from lists.views import home_page
class HomePageTest(TestCase):
@@ -60,22 +60,32 @@ class ListViewTest(TestCase):
-class ItemModelTest(TestCase):
+class ListAndItemModelsTest(TestCase):
def test_saving_and_retrieving_items(self):
+ list_ = List()
+ list_.save()
+
first_item = Item()
first_item.text = 'The first (ever) list item'
+ first_item.list = list_
first_item.save()
second_item = Item()
second_item.text = 'Item the second'
+ second_item.list = list_
second_item.save()
+ saved_list = List.objects.first()
+ self.assertEqual(saved_list, list_)
+
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, 'The first (ever) list item')
+ self.assertEqual(first_saved_item.list, list_)
self.assertEqual(second_saved_item.text, 'Item the second')
+ self.assertEqual(second_saved_item.list, list_)
![]() |
I’m using the variable name list_ to avoid "shadowing" the Python
built-in list function. It’s ugly, but all the other options I tried were
equally ugly or worse (my_list , the_list , list1 , listey …). |
Time for another unit-test/code cycle.
For the first couple of iterations, rather than explicitly showing you what code to enter in between every test run, I’m only going to show you the expected error messages from running the tests. I’ll let you figure out what each minimal code change should be on your own:
Your first error should be:
ImportError: cannot import name 'List'
Fix that, then you should see:
AttributeError: 'List' object has no attribute 'save'
Next you should see:
django.db.utils.OperationalError: no such table: lists_list
So we run a makemigrations
:
$ python3 manage.py makemigrations
Migrations for 'lists':
0003_list.py:
- Create model List
And then you should see:
self.assertEqual(first_saved_item.list, list_)
AttributeError: 'Item' object has no attribute 'list'
A Foreign Key Relationship
How do we give our Item
a list attribute? Let’s just try naively making it
like the text
attribute:
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.TextField(default='')
As usual, the tests tell us we need a migration:
$ python3 manage.py test lists
[...]
django.db.utils.OperationalError: no such column: lists_item.list
$ python3 manage.py makemigrations
Migrations for 'lists':
0004_item_list.py:
- Add field list to item
Let’s see what that gives us:
AssertionError: 'List object' != <List: List object>
We’re not quite there. Look closely at each side of the !=
. Django has only
saved the string representation of the List
object. To save the relationship
to the object itself, we tell Django about the relationship between the two
classes using a ForeignKey
:
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.ForeignKey(List, default=None)
That’ll need a migration too. Since the last one was a red herring, let’s delete it and replace it with a new one:
$ rm lists/migrations/0004_item_list.py
$ python3 manage.py makemigrations
Migrations for 'lists':
0004_item_list.py:
- Add field list to item
![]() |
ask about why deleting migrations is dangerous |
Adjusting the Rest of the World to Our New Models
Back in our tests, now what happens?
$ python3 manage.py test lists
[...]
ERROR: test_displays_all_items (lists.tests.ListViewTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_saving_a_POST_request (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
Ran 7 tests in 0.021s
FAILED (errors=3)
Oh gawd! Still, this is exactly why we have tests.
class ListViewTest(TestCase):
def test_displays_all_items(self):
list_ = List.objects.create()
Item.objects.create(text='itemey 1', list=list_)
Item.objects.create(text='itemey 2', list=list_)
That gets us down to two failing tests, both on tests that try to POST to our
new_list
view. Decoding the tracebacks using our usual technique, working back
from error, to line of test code, to the line of our own code that caused the
failure, we identify:
File "/workspace/superlists/lists/views.py", line 14, in new_list
Item.objects.create(text=request.POST['item_text'])
It’s when we try and create an item without a parent list. So we make a similar change in the view:
from lists.models import Item, List
[...]
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect('/lists/the-only-list-in-the-world/')
And that gets our tests passing again:
OK
![]() |
Are you cringing internally at this point? Ask! |
Anyway, just to reassure ourselves that things have worked, we can re-run the FT.
$ git status # 3 changed files, plus 2 migrations
$ git add lists
$ git diff --staged
$ git commit
And we can cross out another item on the to-do list:
Each List Should Have Its Own URL
class ListViewTest(TestCase):
def test_uses_list_template(self):
list_ = List.objects.create()
response = self.client.get('/lists/%d/' % (list_.id,))
self.assertTemplateUsed(response, 'list.html')
def test_displays_only_items_for_that_list(self):
correct_list = List.objects.create()
Item.objects.create(text='itemey 1', list=correct_list)
Item.objects.create(text='itemey 2', list=correct_list)
other_list = List.objects.create()
Item.objects.create(text='other list item 1', list=other_list)
Item.objects.create(text='other list item 2', list=other_list)
response = self.client.get('/lists/%d/' % (correct_list.id,))
self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2')
self.assertNotContains(response, 'other list item 1')
self.assertNotContains(response, 'other list item 2')
Running the unit tests gives an expected 404, and another related error:
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404
(expected 200)
[...]
FAIL: test_uses_list_template (lists.tests.ListViewTest)
AssertionError: No templates used to render the response
Capturing Parameters from URLs
It’s time to learn how we can pass parameters from URLs to views:
urlpatterns = patterns('',
url(r'^$', 'lists.views.home_page', name='home'),
url(r'^lists/(.+)/$', 'lists.views.view_list', name='view_list'),
url(r'^lists/new$', 'lists.views.new_list', name='new_list'),
# url(r'^admin/', include(admin.site.urls)),
)
But our view doesn’t expect an argument yet! Sure enough, this causes problems:
ERROR: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
ERROR: test_uses_list_template (lists.tests.ListViewTest)
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
We can fix that easily with a dummy parameter in views.py:
def view_list(request, list_id):
[...]
Now we’re down to our expected failure:
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
AssertionError: 1 != 0 : Response should not contain 'other list item 1'
Let’s make our view discriminate over which items it sends to the template:
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
items = Item.objects.filter(list=list_)
return render(request, 'list.html', {'items': items})
Adjusting new_list to the New World
Now we get errors in another test:
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
ValueError: invalid literal for int() with base 10:
'the-only-list-in-the-world'
Let’s take a look at this test then, since it’s whining:
class NewListTest(TestCase):
[...]
def test_redirects_after_POST(self):
response = self.client.post(
'/lists/new',
data={'item_text': 'A new list item'}
)
self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
It looks like it hasn’t been adjusted to the new world of Lists and Items. The test should be saying that this view redirects to the URL of the new list it just created:
def test_redirects_after_POST(self):
response = self.client.post(
'/lists/new',
data={'item_text': 'A new list item'}
)
new_list = List.objects.first()
self.assertRedirects(response, '/lists/%d/' % (new_list.id,))
That still gives us the invalid literal error. We take a look at the view itself, and change it so it redirects to a valid place:
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect('/lists/%d/' % (list_.id,))
That gets us back to passing unit tests. What about the functional tests? We must be almost there?
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Use
peacock feathers to make a fly']
And it correlates nicely with the last item on our to-do list:
This is exactly what we have functional tests for!
One More View to Handle Adding Items to an Existing List
We need a URL and view to handle adding a new item to an existing list ( /lists/<list_id>/add_item). We’re getting pretty good at these now, so let’s knock one together quickly:
class NewItemTest(TestCase):
def test_can_save_a_POST_request_to_an_existing_list(self):
other_list = List.objects.create()
correct_list = List.objects.create()
self.client.post(
'/lists/%d/add_item' % (correct_list.id,),
data={'item_text': 'A new item for an existing list'}
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list)
def test_redirects_to_list_view(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.post(
'/lists/%d/add_item' % (correct_list.id,),
data={'item_text': 'A new item for an existing list'}
)
self.assertRedirects(response, '/lists/%d/' % (correct_list.id,))
We get:
AssertionError: 0 != 1
[...]
AssertionError: 301 != 302 : Response didn't redirect as expected: Response
code was 301 (expected 302)
Beware of Greedy Regular Expressions!
This was a bit of a puzzler, but it’s because we’ve used a very "greedy" regular expression in our URL:
url(r'^lists/(.+)/$', 'lists.views.view_list', name='view_list'),
url(r'^lists/(\d+)/$', 'lists.views.view_list', name='view_list'),
That gives:
AssertionError: 0 != 1
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)
The Last New URL
Now we’ve got our expected 404, let’s add a new URL for adding new items to existing lists:
urlpatterns = patterns('',
url(r'^$', 'lists.views.home_page', name='home'),
url(r'^lists/(\d+)/$', 'lists.views.view_list', name='view_list'),
url(r'^lists/(\d+)/add_item$', 'lists.views.add_item', name='add_item'),
url(r'^lists/new$', 'lists.views.new_list', name='new_list'),
# url(r'^admin/', include(admin.site.urls)),
)
Three very similar-looking URLs there. Let’s make a note on our to-do list; they look like good candidates for a refactoring.
Back to the tests, we now get:
django.core.exceptions.ViewDoesNotExist: Could not import lists.views.add_item.
View does not exist in module lists.views.
The Last New View
Let’s try:
def add_item(request):
pass
Aha:
TypeError: add_item() takes 1 positional argument but 2 were given
def add_item(request, list_id):
pass
And then:
ValueError: The view lists.views.add_item didn't return an HttpResponse object.
It returned None instead.
We can copy the redirect
from new_list
and the List.objects.get
from
view_list
:
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
return redirect('/lists/%d/' % (list_.id,))
That takes us to:
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
Finally we make it save our new list item:
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect('/lists/%d/' % (list_.id,))
And we’re back to passing tests.
Ran 9 tests in 0.050s
OK
But How to Use That URL in the Form?
Now we just need to use this URL in our list.html template. Open it up and adjust the form tag…
<form method="POST" action="but what should we put here?">
<form method="POST" action="/lists/{{ list.id }}/add_item">
For that to work, the view will have to pass the list to the template.
Let’s create a new unit test in ListViewTest
:
def test_passes_correct_list_to_template(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.get('/lists/%d/' % (correct_list.id,))
self.assertEqual(response.context['list'], correct_list)
KeyError: 'list'
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
return render(request, 'list.html', {'list': list_})
That, of course, will break because the template is expecting items
:
AssertionError: False is not true : Couldn't find 'itemey 1' in response
<form method="POST" action="/lists/{{ list.id }}/add_item">
[...]
{% for item in list.item_set.all %}
<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
{% endfor %}
So that gets the unit tests to pass:
Ran 10 tests in 0.060s
OK
How about the FT?
$ python3 manage.py test functional_tests
Creating test database for alias 'default'...
.
---------------------------------------------------------------------
Ran 1 test in 5.824s
OK
Destroying test database for alias 'default'...
Yes! And a quick check on our to-do list:
Irritatingly, the Testing Goat is a stickler for tying up loose ends too, so we’ve got to do this one final thing.
Before we start, we’ll do a commit—always make sure you’ve got a commit of a working state before embarking on a refactor:
$ git diff
$ git commit -am "new URL + view for adding to existing lists. FT passes :-)"
A Final Refactor Using URL includes
$ cp superlists/urls.py lists/
urlpatterns = patterns('',
url(r'^$', 'lists.views.home_page', name='home'),
url(r'^lists/', include('lists.urls')),
# url(r'^admin/', include(admin.site.urls)),
)
from django.conf.urls import patterns, url
urlpatterns = patterns('',
url(r'^(\d+)/$', 'lists.views.view_list', name='view_list'),
url(r'^(\d+)/add_item$', 'lists.views.add_item', name='add_item'),
url(r'^new$', 'lists.views.new_list', name='new_list'),
)
$ git status
$ git add lists/urls.py
$ git add superlists/urls.py
$ git diff --staged
$ git commit