Chapter 2. Extending Our Functional Test Using the unittest Module
Let’s adapt our test, which currently checks for the default Django “it worked” page, and check instead for some of the things we want to see on the real front page of our site.
Time to reveal what kind of web app we’re building: a to-do lists site! In doing so we’re very much following fashion: a few years ago all web tutorials were about building a blog. Then it was forums and polls; nowadays it’s all to-do lists.
The reason is that a to-do list is a really nice example. At its most basic it is very simple indeed—just a list of text strings—so it’s easy to get a “minimum viable” list app up and running. But it can be extended in all sorts of ways—different persistence models, adding deadlines, reminders, sharing with other users, and improving the client-side UI. There’s no reason to be limited to just “to-do” lists either; they could be any kind of lists. But the point is that it should allow me to demonstrate all of the main aspects of web programming, and how you apply TDD to them.
Using a Functional Test to Scope Out a Minimum Viable App
Tests that use Selenium let us drive a real web browser, so they really let us see how the application functions from the user’s point of view. That’s why they’re called functional tests.
This means that an FT can be a sort of specification for your application. It tends to track what you might call a User Story, and follows how the user might work with a particular feature and how the app should respond to them.
FTs should have a human-readable story that we can follow. We make it explicit using comments that accompany the test code. When creating a new FT, we can write the comments first, to capture the key points of the User Story. Being human-readable, you could even share them with nonprogrammers, as a way of discussing the requirements and features of your app.
TDD and agile software development methodologies often go together, and one of the things we often talk about is the minimum viable app; what is the simplest thing we can build that is still useful? Let’s start by building that, so that we can test the water as quickly as possible.
A minimum viable to-do list really only needs to let the user enter some to-do items, and remember them for their next visit.
Open up functional_tests.py and write a story a bit like this one:
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
()
You’ll notice that, apart from writing the test out as comments, I’ve
updated the assert
to look for the word “To-Do” instead of “Django”.
That means we expect the test to fail now. Let’s try running it
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
That’s what we call an expected fail, which is actually good news - not quite as good as a test that passes, but at least it’s failing for the right reason; we can have some confidence we’ve written the test correctly.
The Python Standard Library’s unittest Module
There are a couple of little annoyances we should probably deal with. Firstly, the message “AssertionError” isn’t very helpful—it would be nice if the test told us what it actually found as the browser title. Also, it’s left a Firefox window hanging around the desktop, it would be nice if this would clear up for us automatically.
One option would be to use the second parameter to the assert
keyword,
something like:
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. But
these sorts of problems are quite common in testing, and there are some
ready-made solutions for us in the standard library’s unittest
module. Let’s
use that! In functional_tests.py:
functional_tests.py.
from
selenium
import
webdriver
import
unittest
class
NewVisitorTest
(
unittest
.
TestCase
)
:
#
def
setUp
(
self
)
:
#
self
.
browser
=
webdriver
.
Firefox
(
)
def
tearDown
(
self
)
:
#
self
.
browser
.
quit
(
)
def
test_can_start_a_list_and_retrieve_it_later
(
self
)
:
#
# Edith has heard about a cool new online to-do app. She goes
# to check out its homepage
self
.
browser
.
get
(
'
http://localhost:8000
'
)
# She notices the page title and header mention to-do lists
self
.
assertIn
(
'
To-Do
'
,
self
.
browser
.
title
)
#
self
.
fail
(
'
Finish the test!
'
)
#
# She is invited to enter a to-do item straight away
[
.
.
.
rest
of
comments
as
before
]
if
__name__
==
'
__main__
'
:
#
unittest
.
main
(
warnings
=
'
ignore
'
)
#
You’ll probably notice a few things here:
Tests are organised into classes, which inherit from
unittest.TestCase
.The main body of the test is in a method called
test_can_start_a_list_and_retrieve_it_later
. Any method whose name starts withtest
is a test method, and will be run by the test runner. You can have more than onetest_
method per class. Nice descriptive names for our test methods are a good idea too.setUp
andtearDown
are special methods which get run before and after each test. I’m using them to start and stop our browser—note that they’re a bit like atry/except
, in thattearDown
will run even if there’s an error during the test itself.[4] No more Firefox windows left lying around!We use
self.assertIn
instead of justassert
to make our test assertions.unittest
provides lots of helper functions like this to make test assertions, likeassertEqual
,assertTrue
,assertFalse
, and so on. You can find more in theunittest
documentation.self.fail
just fails no matter what, producing the error message given. I’m using it as a reminder to finish the test.Finally, we have the
if __name__ == '__main__'
clause (if you’ve not seen it before, that’s how a Python script checks if it’s been executed from the command line, rather than just imported by another script). We callunittest.main()
, which launches theunittest
test runner, which will automatically find test classes and methods in the file and run them.warnings='ignore'
suppresses a superfluousResourceWarning
which was being emitted at the time of writing. It may have disappeared by the time you read this; feel free to try removing it!
Note
If you’ve read the Django testing documentation, you might have seen
something called LiveServerTestCase
, and are wondering whether we should
use it now. Full points to you for reading the friendly manual!
LiveServerTestCase
is a bit too complicated for now, but I promise I’ll
use it in a later chapter…
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)
That’s a bit nicer isn’t it? It tidied up our Firefox window, it gives us a
nicely formatted report of how many tests were run and how many failed, and
the assertIn
has given us a helpful error message with useful debugging info.
Bonzer!
Implicit waits
There’s one more thing to do at this stage: add an implicitly_wait
in the
setUp
:
functional_tests.py.
[
...
]
def
setUp
(
self
):
self
.
browser
=
webdriver
.
Firefox
()
self
.
browser
.
implicitly_wait
(
3
)
def
tearDown
(
self
):
[
...
]
This is a standard trope in Selenium tests. Selenium is reasonably good at
waiting for pages to complete loading before it tries to do anything, but it’s
not perfect. The implicitly_wait
tells it to wait a few seconds if it needs
to. When asked to find something on the page, Selenium will now wait up to
three seconds for it to appear.
Warning
Don’t rely on implicitly_wait
; it won’t work for every use case.
It will do its job while our app is still simple, but as we’ll see in Part III
(e.g., in Chapter 15 and Chapter 20), you’ll
want to build more sophisticated, explicit wait algorithms into your tests
once your app gets beyond a certain level of complexity.
Commit
This is a good point to do a commit; it’s a nicely self-contained change. We’ve
expanded our functional test to include comments that describe the task we’re
setting ourselves, our minimum viable to-do list. We’ve also rewritten it to
use the Python unittest
module and its various testing helper functions.
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” (i.e., any
files that we’ve committed before). It won’t add any brand new files (you have
to explicitly git add
them yourself), but often, as in this case, there aren’t
any new files, so it’s a useful shortcut.
When the editor pops up, add a descriptive commit message, like “First FT specced out in comments, and now uses unittest.”
Now we’re in an excellent position to start writing some real code for our lists app. Read on!
Get Test-Driven Development with Python now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.