In the last chapter, our tests failed. This time we'll go about fixing them.

Our First Django App, and Our First Unit Test

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:


In [4]:
#%cd ../examples/superlists/

In [6]:
# Make a new app called lists
#!python3 manage.py startapp lists

In [7]:
!tree .


.
├── 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__
    │   ├── __init__.cpython-35.pyc
    │   ├── settings.cpython-35.pyc
    │   ├── urls.cpython-35.pyc
    │   └── wsgi.cpython-35.pyc
    ├── settings.py
    ├── urls.py
    └── wsgi.py

4 directories, 17 files

Unit Tests, and How They Differ from Functional Tests

The difference boils down to:

  • Functional tests test from the perspective of the user
  • Unit tests test from the point of view of the developer

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:

  1. We start by writing a functional test, describing the new functionality from the user’s point of view.
  2. Once we have a functional test that fails, we start to think about how to write code that can get it to pass (or at least to get past its current failure). We now use one or more unit tests to define how we want our code to behave—the idea is that each line of production code we write should be tested by (at least) one of our unit tests.
  3. Once we have a failing unit test, we write the smallest amount of application code we can, just enough to get the unit test to pass. We may iterate between steps 2 and 3 a few times, until we think the functional test will get a little further.
  4. Now we can rerun our functional tests and see if they pass, or get a little further. That may prompt us to write some new unit tests, and some new code, and so on.

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.

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:


In [ ]:
# %load 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:


In [10]:
%%writefile lists/tests.py

from django.test import TestCase

class SmokeTest(TestCase):
    
    def test_bad_maths(self):
        self.assertEqual(1 + 1, 3)


Overwriting lists/tests.py

Run our new django test


In [11]:
!python3 manage.py test


Creating test database for alias 'default'...
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/thunder/Documents/work/codeguild2015/code_guild/wk9/examples/superlists/lists/tests.py", line 7, 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'...

Everything seems to be working! (This would be a good time to 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"

Django's MVC, URLs, and View Functions

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:

  1. An HTTP request comes in for a particular URL.
  2. Django uses some rules to decide which view function should deal with the request (this is referred to as resolving the URL).
  3. The view function processes the request and returns an HTTP response.

So we want to test two things:

  • Can we resolve the URL for the root of the site (“/”) to a particular view function we’ve made?

  • Can we make this view function return some HTML which will get the functional test to pass?

Let’s start with the first. Open up lists/tests.py, and change our silly test to something like this:


In [12]:
%%writefile lists/tests.py

from django.core.urlresolvers import resolve
from django.test import TestCase
from lists.views import home_page #1

class HomePageTest(TestCase):
    
    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')  #2
        self.assertEqual(found.func, home_page)  #3


Overwriting lists/tests.py

What’s going on here?

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

  2. and

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

So, what do you think will happen when we run the tests?


In [13]:
!python3 manage.py test


Creating test database for alias 'default'...
E
======================================================================
ERROR: lists.tests (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: lists.tests
Traceback (most recent call last):
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/unittest/loader.py", line 428, in _find_test_path
    module = self._get_module_from_name(name)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/unittest/loader.py", line 369, in _get_module_from_name
    __import__(name)
  File "/home/thunder/Documents/work/codeguild2015/code_guild/wk9/examples/superlists/lists/tests.py", line 4, in <module>
    from lists.views import home_page #1
ImportError: cannot import name 'home_page'


----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)
Destroying test database for alias 'default'...

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.

At Last! We Actually Write Some Application Code!

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:


In [14]:
%%writefile lists/views.py

from django.shortcuts import render

# Create your views here.
home_page = None


Overwriting lists/views.py

In [15]:
!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 "/home/thunder/Documents/work/codeguild2015/code_guild/wk9/examples/superlists/lists/tests.py", line 9, in test_root_url_resolves_to_home_page_view
    found = resolve('/')  #2
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 522, in resolve
    return get_resolver(urlconf).resolve(path)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 388, 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'...

we interpret the traceback as telling us that, when trying to resolve “/”, Django raised a 404 error—in other words, Django can’t find a URL mapping for “/”. Let’s help it out.

urls.py

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:


In [ ]:
# %load superlists/urls.py

"""superlists URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/1.8/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
Including another URLconf
    1. Add an import:  from blog import urls as blog_urls
    2. Add a URL to urlpatterns:  url(r'^blog/', include(blog_urls))
"""

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
]

The first example entry 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 include it?


In [21]:
%%writefile superlists/urls.py
"""superlists URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/1.8/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
Including another URLconf
    1. Add an import:  from blog import urls as blog_urls
    2. Add a URL to urlpatterns:  url(r'^blog/', include(blog_urls))
"""

from django.conf.urls import include, url
from django.contrib import admin

from lists import views

urlpatterns = [
    url(r'^$', views.home_page, name='home'),
    #url(r'^admin/', include(admin.site.urls)),
]


Overwriting superlists/urls.py

In [22]:
!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 "/home/thunder/Documents/work/codeguild2015/code_guild/wk9/examples/superlists/lists/tests.py", line 9, in test_root_url_resolves_to_home_page_view
    found = resolve('/')  #2
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 522, in resolve
    return get_resolver(urlconf).resolve(path)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 368, in resolve
    sub_match = pattern.resolve(new_path)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 240, in resolve
    return ResolverMatch(self.callback, args, kwargs, self.name)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 247, in callback
    self._callback = get_callable(self._callback_str)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 96, in get_callable
    mod_name, func_name = get_mod_func(lookup_view)
  File "/home/thunder/anaconda3/envs/django_env/lib/python3.5/site-packages/django/core/urlresolvers.py", line 159, in get_mod_func
    dot = callback.rindex('.')
AttributeError: 'NoneType' object has no attribute 'rindex'

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)
Destroying test database for alias 'default'...

That’s progress! We’re no longer getting a 404.

The message is slightly cryptic, but the unit tests have actually made the link between the URL / and the home_page = None in lists/views.py, and are now complaining that home_page is a NoneType. And that gives us a justification for changing it from being None to being an actual function. Every single code change is driven by the tests!


In [27]:
%%writefile lists/views.py

from django.shortcuts import render

# Create your views here.
def home_page():
    pass


Overwriting lists/views.py

In [28]:
!python3 manage.py test


Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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"

Unit Testing a View

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:


In [30]:
%%writefile 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() #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>'))  #5


Overwriting lists/tests.py

What’s going on in this new test?

  1. We create an HttpRequest object, which is what Django will see when a user’s browser asks for a page.

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

  3. & 5. 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.

  4. 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:


In [31]:
!python3 manage.py test


Creating test database for alias 'default'...
E.
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/thunder/Documents/work/codeguild2015/code_guild/wk9/examples/superlists/lists/tests.py", line 16, in test_home_page_returns_correct_html
    response = home_page(request)  #2
TypeError: home_page() takes 0 positional arguments but 1 was given

----------------------------------------------------------------------
Ran 2 tests in 0.002s

FAILED (errors=1)
Destroying test database for alias 'default'...

The Unit-Test/Code Cycle

We can start to settle into the TDD unit-test/code cycle now:

In the terminal, run the unit tests and see how they fail. In the editor, make a minimal code change to address the current test failure. 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:

Minimal code change:

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 2 tests in 0.001s

OK
Destroying test database for alias 'default'...

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:

  • 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

In [37]:
%%writefile lists/views.py

from django.shortcuts import render
from django.http import HttpResponse

# Create your views here.
def home_page(request):
    return HttpResponse('<html><title>To-Do lists</title></html>')


Overwriting lists/views.py

In [38]:
!python3 manage.py test


Creating test database for alias 'default'...
..
----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK
Destroying test database for alias 'default'...