We finished the last chapter with a functional test failing, telling us that it wanted the home page for our site to have “To-Do” in its title. It’s time to start working on our application.
Django encourages you to structure your code into apps: the theory is that one project can have many apps, you can use third-party apps developed by other people, and you might even reuse one of your own apps in a different project … although I admit I’ve never actually managed it myself! Still, apps are a good way to keep your code organised.
Let’s start an app for our to-do lists:
$ 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
As with so many of the labels we put on things, the line between unit tests and functional tests can become a little blurry at times. The basic distinction, though, is that functional tests test the application from the outside, from the point of view of the user. Unit tests test the application from the inside, from the point of view of the programmer.
The TDD approach I’m following wants our application to be covered by both types of test. Our workflow will look a bit like this:
You can see that, all the way through, the functional tests are driving what development we do from a high level, while the unit tests drive what we do at a low level.
Does that seem slightly redundant? Sometimes it can feel that way, but functional tests and unit tests do really have very different objectives, and they will usually end up looking quite different.
Functional tests should help you build an application with the right functionality, and guarantee you never accidentally break it. Unit tests should help you to write code that’s clean and bug free.
Enough theory for now, let’s see how it looks in practice.
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.
Django has helpfully suggested we use a special version of TestCase
, which
it provides. It’s an augmented version of the standard unittest.TestCase
,
with some additional Django-specific features, which we’ll discover over the
next few chapters.
You’ve already seen that the TDD cycle involves starting with a test that fails, then writing code to get it to pass. Well, before we can even get that far, we want to know that the unit test we’re writing will definitely be run by our automated test runner, whatever it is. In the case of functional_tests.py, we’re running it directly, but this file made by Django is a bit more like magic. So, just to make sure, let’s make a deliberately silly failing test:
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
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 you’ve no doubt guessed, the -m
flag lets you pass in a commit message
at the command line, so you don’t need to use an editor. It’s up to you
to pick the way you like to use the Git command line, I’ll just show you
the main ones I’ve seen used. The main rule is: make sure you always review
what you’re about to commit before you do it.
Django is broadly structured along a classic Model-View-Controller (MVC) pattern. Well, broadly. It definitely does have models, but its views are more like a controller, and it’s the templates that are actually the view part, but the general idea is there. If you’re interested, you can look up the finer points of the discussion in the Django FAQs.
Irrespective of any of that, like any web server, Django’s main job is to decide what to do when a user asks for a particular URL on our site. Django’s workflow goes something like this:
So we want to test two things:
Let’s start with the first. Open up lists/tests.py, and change our silly test to something like this:
lists/tests.py.
from
django.core.urlresolvers
import
resolve
from
django.test
import
TestCase
from
lists.views
import
home_page
#
class
HomePageTest
(
TestCase
):
def
test_root_url_resolves_to_home_page_view
(
self
):
found
=
resolve
(
'/'
)
#
self
.
assertEqual
(
found
.
func
,
home_page
)
#
What’s going on here?
resolve
is the function Django uses internally to resolve
URLs, and find what view function they should map to. We’re checking that
resolve
, when called with “/”, the root of the site, finds a function
called home_page
.
What function is that? It’s the view function we’re going to
write next, which will actually return the HTML we want. You can see from
the import
that we’re planning to store it in lists/views.py.
So, what do you think will happen when we run the tests?
$ python3 manage.py test
ImportError: cannot import name 'home_page'
It’s a very predictable and uninteresting error: we tried to import something we haven’t even written yet. But it’s still good news—for the purposes of TDD, an exception which was predicted counts as an expected failure. Since we have both a failing functional test and a failing unit test, we have the Testing Goat’s full blessing to code away.
It is exciting isn’t it? Be warned, TDD means that long periods of anticipation are only defused very gradually, and by tiny increments. Especially since we’re learning and only just starting out, we only allow ourselves to change (or add) one line of code at a time—and each time, we make just the minimal change required to address the current test failure.
I’m being deliberately extreme here, but what’s our current test failure?
We can’t import home_page
from lists.views
? OK, let’s fix that—and only
that. In lists/views.py:
lists/views.py.
from
django.shortcuts
import
render
# Create your views here.
home_page
=
None
"You must be joking!" I can hear you say.
I can hear you because it’s what I used to say (with feeling) when my colleagues first demonstrated TDD to me. Well, bear with me, we’ll talk about whether or not this is all taking it too far in a little while. For now, let yourself follow along, even if it’s with some exasperation, and see where it takes us.
Let’s run the tests again:
$ python3 manage.py test Creating test database for alias 'default'... E ====================================================================== ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest) --------------------------------------------------------------------- Traceback (most recent call last): File "/workspace/superlists/lists/tests.py", line 8, in test_root_url_resolves_to_home_page_view found = resolve('/') File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py", line 485, in resolve return get_resolver(urlconf).resolve(path) File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py", line 353, in resolve raise Resolver404({'tried': tried, 'path': new_path}) django.core.urlresolvers.Resolver404: {'tried': [[<RegexURLResolver <RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''} --------------------------------------------------------------------- Ran 1 test in 0.002s FAILED (errors=1) Destroying test database for alias 'default'...
Django uses a file called urls.py to define how URLs map to view functions. There’s a main urls.py for the whole site in the superlists/superlists folder. Let’s go take a look:
superlists/urls.py.
from
django.conf.urls
import
patterns
,
include
,
url
from
django.contrib
import
admin
urlpatterns
=
patterns
(
''
,
# Examples:
# url(r'^$', 'superlists.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
url
(
r'^admin/'
,
include
(
admin
.
site
.
urls
)),
)
As usual, lots of helpful comments and default suggestions from Django.
A url
entry starts with a regular expression that defines which URLs it
applies to, and goes on to say where it should send those requests—either to
a dot-notation encoded function like superlists.views.home
, or maybe to
another urls.py file somewhere else using include
.
You can see there’s one entry in there by default there for the admin site. We’re not using that yet, so let’s comment it out for now:
superlists/urls.py.
from
django.conf.urls
import
patterns
,
include
,
url
from
django.contrib
import
admin
urlpatterns
=
patterns
(
''
,
# Examples:
# url(r'^$', 'superlists.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
# url(r'^admin/', include(admin.site.urls)),
)
The first entry in urlpatterns
has the regular expression ^$
, which means
an empty string—could this be the same as the root of our site, which we’ve
been testing with “/”? Let’s find out—what happens if we uncomment that
line?
If you’ve never come across regular expressions, you can get away with just taking my word for it, for now—but you should make a mental note to go learn about them.
superlists/urls.py.
urlpatterns
=
patterns
(
''
,
# Examples:
url
(
r'^$'
,
'superlists.views.home'
,
name
=
'home'
),
# url(r'^blog/', include('blog.urls')),
# url(r'^admin/', include(admin.site.urls)),
)
Run the unit tests again, with python3 manage.py test
:
ImportError: No module named 'superlists.views' [...] django.core.exceptions.ViewDoesNotExist: Could not import superlists.views.home. Parent module superlists.views does not exist.
That’s progress! We’re no longer getting a 404; instead Django is complaining
that the dot-notation superlists.views.home
doesn’t point to a real view.
Let’s fix that, by pointing it towards our placeholder home_page
object,
which is inside lists, not superlists:
superlists/urls.py.
urlpatterns
=
patterns
(
''
,
# Examples:
url
(
r'^$'
,
'lists.views.home_page'
,
name
=
'home'
),
And run the tests again:
django.core.exceptions.ViewDoesNotExist: Could not import lists.views.home_page. View is not callable.
The unit tests have made the link between the URL / and the home_page =
None
in lists/views.py, and are now complaining that home_page
isn’t a
callable; ie, it’s not a function. Now we’ve got a justification for changing it
from being None
to being an actual function. Every single code change is
driven by the tests. Back in lists/views.py:
lists/views.py.
from
django.shortcuts
import
render
# Create your views here.
def
home_page
():
pass
And now?
$ python3 manage.py test
Creating test database for alias 'default'...
.
---------------------------------------------------------------------
Ran 1 test in 0.003s
OK
Destroying test database for alias 'default'...
Hooray! Our first ever unit test pass! That’s so momentous that I think it’s worthy of a commit:
$ git diff # should show changes to urls.py, tests.py, and views.py $ git commit -am "First unit test and url mapping, dummy view"
That was the last variation on git commit
I’ll show, the a
and m
flags
together, which adds all changes to tracked files and uses the commit message
from the command line.
git commit -am
is the quickest formulation, but also gives you the
least feedback about what’s being committed, so make sure you’ve done a
git status
and a git diff
beforehand, and are clear on what changes are
about to go in.
On to writing a test for our view, so that it can be something more than a do-nothing function, and instead be a function that returns a real response with HTML to the browser. Open up lists/tests.py, and add a new test method. I’ll explain each bit:
lists/tests.py.
from
django.core.urlresolvers
import
resolve
from
django.test
import
TestCase
from
django.http
import
HttpRequest
from
lists.views
import
home_page
class
HomePageTest
(
TestCase
):
def
test_root_url_resolves_to_home_page_view
(
self
):
found
=
resolve
(
'/'
)
self
.
assertEqual
(
found
.
func
,
home_page
)
def
test_home_page_returns_correct_html
(
self
):
request
=
HttpRequest
()
#
response
=
home_page
(
request
)
#
self
.
assertTrue
(
response
.
content
.
startswith
(
b
'<html>'
))
#
self
.
assertIn
(
b
'<title>To-Do lists</title>'
,
response
.
content
)
#
self
.
assertTrue
(
response
.
content
.
endswith
(
b
'</html>'
))
#
What’s going on in this new test?
We create an HttpRequest
object, which is what Django will see when
a user’s browser asks for a page.
We pass it to our home_page
view, which gives us a response. You won’t be
surprised to hear that this object is an instance of a class called HttpResponse
.
Then, we assert that the .content
of the response—which is the HTML
that we send to the user—has certain properties.
We want it to start with an <html>
tag which gets closed at the end.
Notice that response.content
is raw bytes, not a Python string, so we
have to use the b''
syntax to compare them. More info is available in Django’s
Porting to Python 3
docs.
And we want a <title>
tag somewhere in the middle, with the words
“To-Do lists” in it—because that’s what we specified in our functional test.
Once again, the unit test is driven by the functional test, but it’s also much closer to the actual code—we’re thinking like programmers now.
Let’s run the unit tests now and see how we get on:
TypeError: home_page() takes 0 positional arguments but 1 was given
We can start to settle into the TDD unit-test/code cycle now:
And repeat!
The more nervous we are about getting our code right, the smaller and more minimal we make each code change—the idea is to be absolutely sure that each bit of code is justified by a test. It may seem laborious, but once you get into the swing of things, it really moves quite fast—so much so that, at work, we usually keep our code changes microscopic even when we’re confident we could skip ahead.
Let’s see how fast we can get this cycle going:
lists/views.py.
def
home_page
(
request
):
pass
self.assertTrue(response.content.startswith(b'<html>')) AttributeError: 'NoneType' object has no attribute 'content'
django.http.HttpResponse
, as predicted:
lists/views.py.
from
django.http
import
HttpResponse
# Create your views here.
def
home_page
(
request
):
return
HttpResponse
()
self.assertTrue(response.content.startswith(b'<html>')) AssertionError: False is not true
lists/views.py.
def
home_page
(
request
):
return
HttpResponse
(
'<html>'
)
AssertionError: b'<title>To-Do lists</title>' not found in b'<html>'
lists/views.py.
def
home_page
(
request
):
return
HttpResponse
(
'<html><title>To-Do lists</title>'
)
self.assertTrue(response.content.endswith(b'</html>')) AssertionError: False is not true
lists/views.py.
def
home_page
(
request
):
return
HttpResponse
(
'<html><title>To-Do lists</title></html>'
)
$ python3 manage.py test
Creating test database for alias 'default'...
..
---------------------------------------------------------------------
Ran 2 tests in 0.001s
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 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)
Failed? What? Oh, it’s just our little reminder? Yes? Yes! We have a web page!
Ahem. Well, I thought it was a thrilling end to the chapter. You may still be a little baffled, perhaps keen to hear a justification for all these tests, and don’t worry, all that will come, but I hope you felt just a tinge of excitement near the end there.
Just a little commit to calm down, and reflect on what we’ve covered:
$ git diff # should show our new test in tests.py, and the view in views.py $ git commit -am "Basic view now returns minimal HTML"
That was quite a chapter! Why not try typing git log
, possibly using the
--oneline
flag, for a reminder of what we got up to:
$ git log --oneline
a6e6cc9 Basic view now returns minimal HTML
450c0f3 First unit test and url mapping, dummy view
ea2b037 Add app for lists, with deliberately failing unit test
[...]
Not bad—we covered: