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.

Windows Notes

Windows users can sometimes feel a little neglected, since OS X and Linux make it easy to forget there’s a world outside the Unix paradigm. Backslashes as directory separators? Drive letters? What? Still, it is absolutely possible to follow along with this book on Windows. Here are a few tips:

  1. When you install Git for Windows, make sure you choose "Run Git and included Unix tools from the Windows command prompt". You’ll then get access to a program called "Git Bash". Use this as your main command prompt and you’ll get all the useful GNU command-line tools like ls, touch, and grep, plus forward-slash directory separators.

  2. When you install Python 3, make sure you tick the option that says "add python.exe to Path" as in [add-python-to-path], to make sure you can run Python from the command line.

    Screenshot of python installer
    Figure 1. Add python to the system path from the installer
  3. On Windows, Python 3’s executable is called python.exe, which is exactly the same as Python 2. To avoid any confusion, create a symlink in the Git Bash binaries folder, like this:

    ln -s /c/Python34/python.exe /bin/python3.exe

    You may need to right-click Git-Bash and choose "Run as an administrator" for that command to work. Note also that the symlink will only work in Git Bash, not in the regular DOS command prompt.

  4. Python 3.4 comes with pip, the package management tool. You can check it’s installed by doing a which pip3 from a command line, and it should show you /c/Python34/Scripts/pip3.

    If, for whatever reason, you’re stuck with Python 3.3 and you don’t have pip3, check http://www.pip-installer.org/ for installation instructions. At the time of writing, this involved downloading a file and then executing it with python3 get-pip.py. Make sure you use python3 when you run the setup script.

Tip The test for all this is that you should be able to go to a Git-Bash command prompt and just run python3 or pip3 from any folder.
MacOS Notes

MacOS is a bit more sane than Windows, although getting pip3 installed was still fairly challenging up until recently. With the arrival of 3.4, things are now quite straightforward:

  • Python 3.4 should install without a fuss from its downloadable installer. It will automatically install pip, too.

  • Git’s installer should also "just work".

Similarly to Windows, the test for all this is that you should be able to open a terminal and just run git, python3, or pip3 from anywhere. If you run into any trouble, the search terms "system path" and "command not found" should provide good troubleshooting resources.

Tip You might also want to check out Homebrew. It used to be the only reliable way of installing lots of Unixy tools (including Python 3) on a Mac. Although the Python installer is now fine, you may find it useful in future. It does require you to download all 1.1 GB of Xcode, but that also gives you a C compiler, which is a useful side effect.

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 the sudo on Windows). This is our web framework. You should make sure you have version 1.7 installed and that you can access the django-admin.py executable from a command line. The Django documentation has some installation instructions if you need help.

Note 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 the sudo on Windows)

Note 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

A picture of a goat up a tree
Figure 2. Goats are more agile than you think (source: Caitlin Stewart, on Flickr)

Obey the Testing Goat! Do Nothing Until You Have a Test

Here’s about the simplest imaginable test:

functional_tests.py
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].

Screenshot of Django
Figure 3. It worked!

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/
Note 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!

Advanced Exercise:

Can you swap out Firefox for another browser? Chrome? You’ll need something called ChromeDriver. Also, check out PhantomJS

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:

functional_tests.py
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:

functional_tests.py
from selenium import webdriver
import unittest

class NewVisitorTest(unittest.TestCase):  #1

    def setUp(self):  #3
        self.browser = webdriver.Firefox()

    def tearDown(self):  #3
        self.browser.quit()

    def test_can_start_a_list_and_retrieve_it_later(self):  #2
        # 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)  #4
        self.fail('Finish the test!')  #5

        # She is invited to enter a to-do item straight away
        [...rest of comments as before]

if __name__ == '__main__':  #6
    unittest.main(warnings='ignore')  #7

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

functional_tests.py
[...]
def setUp(self):
    self.browser = webdriver.Firefox()
    self.browser.implicitly_wait(3)

def tearDown(self):
[...]
Advanced Exercise:

Find out about the addCleanup function. How would you use it in this case? What would it replace? What do you think the advantages and disadvantages might be?

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”.

Useful TDD Concepts
User Story

A description of how the application will work from the point of view of the user. Used to structure a functional test.

Expected failure

When a test fails in the way that we expected it to.

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

Note 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:

lists/tests.py
from django.test import TestCase

# Create your tests here.

Let’s deliberately create a breaking test and see if we can see it fail.

lists/tests.py
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 command:

$ 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

lists/tests.py
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() #1
        response = home_page(request) #2
        self.assertTrue(response.content.startswith(b'<html>')) #3
        self.assertIn(b'<title>To-Do lists</title>', response.content) #4
        self.assertTrue(response.content.endswith(b'</html>')) #3

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?

lists/views.py
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:

lists/views.py
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:

lists/views.py
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:

lists/views.py
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:

lists/views.py
def home_page(request):
    return HttpResponse('<html>')
  • Tests:

AssertionError: b'<title>To-Do lists</title>' not found in b'<html>'
  • Code:

lists/views.py
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:

lists/views.py
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

superlists/urls.py
urlpatterns = patterns('',
    # Examples:
    url(r'^$', 'lists.views.home_page', name='home'),
    [...]
Advanced Exercise:

How would you write a unit test for urls.py? Hint: look into the Django reverse and resolve functions.

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

Useful Commands and Concepts
Running the Django dev server

python3 manage.py runserver

Running the functional tests

python3 functional_tests.py

Running the unit tests

python3 manage.py test

The unit-test/code cycle
  1. Run the unit tests in the terminal.

  2. Make a minimal code change in the editor.

  3. Repeat!

What Are We Doing with All These Tests?

Test ALL the things
Figure 4. Test ALL the things (original illustration source: Allie Brosh, Hyperbole and a Half)

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:

functional_tests.py
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_elements_by_tag_name (notice the extra s) * send_keys, * Keys class * any function + generator expressions

Tip 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
lists/templates/home.html
<html>
    <title>To-Do lists</title>
</html>

Mmmh, syntax-highlighted … much nicer! Now to change our view function:

lists/views.py
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)2
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/workspace/superlists/lists/tests.py", line 17, in
test_home_page_returns_correct_html
    response = home_page(request)3
  File "/workspace/superlists/lists/views.py", line 5, in home_page
    return render(request, 'home.html')4
  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.html1

 ---------------------------------------------------------------------
Ran 2 tests in 0.004s

Another chance to analyse a traceback:

1 We start with the error: it can’t find the template.
2 Then we double-check what test is failing: sure enough, it’s our test of the view HTML.
3 Then we find the line in our tests that caused the failure: it’s when we call the home_page function.
4 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:

superlists/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.

Note 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.
lists/tests.py
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

lists/tests.py
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

Advanced Exercise:

Look up the Django Test Client. How would you use it to check that our view is using the correct template?

On refactoring

Am I recommending that you actually work this way? No. I’m recommending that you be able to work this way.

TDD by example
— Kent Beck
Tip When refactoring, work on either the code or the tests, but not both at once.
An adventurous cat
Figure 5. Refactoring Cat—be sure to look up the full animated GIF (source: 4GIFs.com)

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.

lists/templates/home.html
<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…

lists/templates/home.html
    [...]
        <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…

lists/templates/home.html
    <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…

lists/templates/home.html
    <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!

functional_tests.py
    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.

Advanced Exercise:

How could we improve that check-that-the-new-item-has-appeared-in-the-table assertion further?

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].

A flowchart showing tests
Figure 6. Overall TDD process
A flowchart showing functional tests as the overall cycle
Figure 7. The TDD process with functional and unit tests
How to "Check" Your Code, or Skip Ahead (If You Must)

All of the code examples I’ve used in the book are available in my repo on GitHub. So, if you ever want to compare your code against mine, you can take a look at it there.

Each chapter has its own branch following the convention chapter_XX:

Be aware that each branch contains all of the commits for that chapter, so its state represents the code at the end of the chapter.

Using Git to check your progress

If you feel like developing your Git-Fu a little further, you can add my repo as a remote:

git remote add harry https://github.com/hjwp/book-example.git
git fetch harry

And then, to check your difference from the end of [chapter-4]:

git diff harry/chapter_04

Git can handle multiple remotes, so you can still do this even if you’re already pushing your code up to GitHub or Bitbucket.

Be aware that the precise order of, say, methods in a class may differ between your version and mine. It may make diffs hard to read.

Downloading a ZIP file for a chapter

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:

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.

functional_tests.py
    # 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')
Django DEBUG page showing CSRF error
Figure 8. Django DEBUG page showing CSRF error
Note check out Ross Anderson’s "Security Engineering"

csrf token magic:

lists/templates/home.html
<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:

functional_tests.py
    # "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

lists/tests.py (ch05l005)
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:

lists/views.py
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

lists/templates/home.html
<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:

lists/tests.py
    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:

lists/views.py (ch05l009)
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.

lists/views.py
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:

functional_tests.py
    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?

functional_tests.py
    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']
lists/templates/home.html
    <tr><td>1: {{ new_item_text }}</td></tr>
Note 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:

functional_tests.py
    # 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:

functional_tests.py
    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:

functional_tests.py
    # 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:

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.

Note 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].
Advanced Exercise:

What’s the simplest version of this test that still thoroughly tests the class?

Note Ask about integrated vs pure unit tests, and "the database is Hot Lava!"
ImportError: cannot import name 'Item'
lists/models.py
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:

lists/models.py
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
Note 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:

lists/models.py
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:

lists/models.py
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

lists/tests.py
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)  #1
    new_item = Item.objects.first()  #2
    self.assertEqual(new_item.text, 'A new list item')  #3

    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:

  • Code smell: POST test is too long?

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

Let’s adjust our view:

lists/views.py
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', ''),
    })
lists/views.py
    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:

  • Don’t save blank items for every request

  • Code smell: POST test is too long?

  • Display multiple items in the table

  • Support more than one list!

lists/tests.py
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.

lists/views.py
def home_page(request):
    if request.method == 'POST':
        new_item_text = request.POST['item_text']  #1
        Item.objects.create(text=new_item_text)  #2
    else:
        new_item_text = ''  #1

    return render(request, 'home.html', {
        'new_item_text': new_item_text,  #1
    })
Ran 5 tests in 0.010s

OK

Redirect After a POST

lists/tests.py
    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:

lists/views.py (ch05l028)
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
Advanced exercise: each test should test one thing
lists/tests.py
    def test_home_page_can_save_a_POST_request(self):
        ?


    def test_home_page_redirects_after_POST(self):
        ?

And we should now see six tests pass instead of five:

Ran 6 tests in 0.010s

OK

Rendering Items in the Template

Back to our to-do list:

  • Don’t save blank items for every request

  • Code smell: POST test is too long?

  • Display multiple items in the table

  • Support more than one list!

lists/tests.py
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 [...]
lists/templates/home.html
<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:

lists/views.py
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})
Advanced Exercise:

If you didn’t find out about it in the last chapter, find out about the Django Test Client. How would you re-write our test(s) for the home page view now?

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].

OperationalError at / no such table: lists_item
Figure 9. Another helpful debug message

Creating Our Production Database with migrate

superlists/settings.py
[...]
# 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:

lists/templates/home.html
    {% 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"
Tip 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.

Useful TDD Concepts
Regression

When new code breaks some aspect of the application which used to work.

Unexpected failure

When a test fails in a way we weren’t expecting. This either means that we’ve made a mistake in our tests, or that the tests have helped us find a regression, and we need to fix something in our code.

Red/Green/Refactor

Another way of describing the TDD process. Write a test and see it fail (Red), write some code to get it to pass (Green), then Refactor to improve the implementation.

Triangulation

Adding a test case with a new specific example for some existing code, to justify generalising the implementation (which may be a "cheat" until that point).

Three strikes and refactor

A rule of thumb for when to remove duplication from code. When two pieces of code look very similar, it often pays to wait until you see a third use case, so that you’re more sure about what part of the code really is the common, re-usable part to refactor out.

The scratchpad to-do list

A place to write down things that occur to us as we’re coding, so that we can finish up what we’re doing and come back to them later.

Getting to the Minimum Viable Site

  1. 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.

Note 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:

functional_tests/tests.py (ch06l001)
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:

functional_tests/tests.py (ch06l002)
    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!

Avanced Exercise: hand-rolled database cleanup

Before LiveServerTestCase, you had to do this stuff yourself! And it still has some benefits.

You can check out one of my old attempts, for inspiration, if you like: old-ft-runner

$ 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'...
Useful Commands Updated
To run the functional tests

python3 manage.py test functional_tests

To run the unit tests

python3 manage.py test lists

Currently the FT says this:

functional_tests/tests.py
    # 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:

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

Implementing the New Design Using TDD

functional_tests/tests.py
    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/.+') #1
    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:

functional_tests/tests.py
    [...]
    # 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 #1
    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:

lists/tests.py
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
Note 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:

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

lists/tests.py (ch06l009)
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/') #1

        self.assertContains(response, 'itemey 1') #2
        self.assertContains(response, 'itemey 2') #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.

Tip Watch out for trailing slashes in URLs, both here in the tests and in urls.py—They’re a common source of bugs.
superlists/urls.py
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:

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:

lists/views.py
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

lists/tests.py
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:

lists/views.py
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
lists/templates/home.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>
lists/templates/list.html
    <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:

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/')
    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…

lists/templates/list.html (ch06l019)
    <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?

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

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:

Note if you didn’t do the advanced exercise in the last section, you’ll only have one method here. That’s fine.
lists/tests.py (ch06l021-1)
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:

lists/tests.py (ch06l021-2)
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:

superlists/urls.py
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:

lists/views.py
def new_list(request):
    pass
lists/views.py
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:

lists/views.py
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:

lists/tests.py
    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?

lists/views.py
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:

lists/templates/home.html, lists/templates/list.html
    <form method="POST" action="/lists/new">
Advanced Exercise

We’re violating DRY with all these hard-coded URLs. Investigate the Django {% url %} tag, and the reverse and redirect functions, and use them as appropriate

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:

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

Adjusting Our Models

Just for fun, a diff output instead of a plain code listing:

lists/tests.py
@@ -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_)
Note 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:

lists/models.py
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:

lists/models.py
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
Note ask about why deleting migrations is dangerous
Advanced Exercise

If you switch to a CharField instead of a TextField, you’ll see we need a max_length attribute. How would you test that? It’s not obvious, so ask for a hint if you need one…

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.

lists/tests.py (ch06l031)
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:

lists/views.py
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
Note 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:

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

Each List Should Have Its Own URL

lists/tests.py (ch06l033-1)
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:

superlists/urls.py
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:

lists/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:

lists/views.py
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:

lists/tests.py
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:

lists/tests.py (ch06l036-1)
    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:

lists/views.py (ch06l036-2)
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:

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

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:

lists/tests.py
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'),
superlists/urls.py
    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:

superlists/urls.py
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.

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

  • Refactor away some duplication in urls.py

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:

lists/views.py
def add_item(request):
    pass

Aha:

TypeError: add_item() takes 1 positional argument but 2 were given
lists/views.py
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:

lists/views.py
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:

lists/views.py
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…

lists/templates/list.html
    <form method="POST" action="but what should we put here?">
lists/templates/list.html
    <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:

lists/tests.py (ch06l041)
    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'
lists/views.py
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
lists/templates/list.html (ch06l043)
    <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:

  • Get FTs to clean up after themselves

  • Adjust model so that items are associated with different lists

  • Add unique URLs for each list

  • Add a URL for creating a new list via POST

  • Add URLs for adding a new item to an existing list via POST

  • Refactor away some duplication in urls.py

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/
superlists/urls.py
urlpatterns = patterns('',
    url(r'^$', 'lists.views.home_page', name='home'),
    url(r'^lists/', include('lists.urls')),
    # url(r'^admin/', include(admin.site.urls)),
)
lists/urls.py (ch06l045)
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
Useful TDD Concepts and Rules Of Thumb
Test Isolation and Global State

Different tests shouldn’t affect one another. This means we need to reset any permanent state at the end of each test. Django’s test runner helps us do this by creating a test database, which it wipes clean in between each test. (See also [isolation-chapter].)

Working State to Working State (aka The Testing Goat vs. Refactoring Cat)

Our natural urge is often to dive in and fix everything at once … but if we’re not careful, we’ll end up like Refactoring Cat, in a situation with loads of changes to our code and nothing working. The Testing Goat encourages us to take one step at a time, and go from working state to working state.

YAGNI

You ain’t gonna need it! Avoid the temptation to write code that you think might be useful, just because it suggests itself at the time. Chances are, you won’t use it, or you won’t have anticipated your future requirements correctly. See [outside-in-chapter] for one methodology that helps us avoid this trap.

Follow up!

Find the full book online at:

The Testing Goat says bye!

1. If you get a different error at this point, try restarting your dev server—it may have gotten confused by the changes to the database happening under its feet.