The following series is meant to be a hands-on introduction to selected tools that can be used for automated Django testing. You can find all the parts here:
- Testing Django - part 1 - nose
- Testing Django - part 2 - lettuce
- Testing Django - part 3 - tdaemon
- Testing Django - part 4 - setting up a dedicated CouchDB for tests
Intro
If you want to bring the good practice of automated testing or even Test Driven Development (TDD) to your Django project you have many different testing framework to choose from. In the first part of this series I want to introduce nose, in the second part we will have a look at Behavior Driven Development (BDD) with lettuce.
It is important to keep in mind that there are different levels of testing. The most important once are:
- unit testing
- integration testing
- system testing
Python's standard libraries and Django offer the unittest and doctest libs but I personally prefer the nose testing framework as it is powerful and has an easy syntax. Actually you can also use tests written for unittest or doctest and use nose a the test runner. You can even mix different types of testing styles. This is what I would call flexible.
Starting a project
Let's start a little dummy project with one app. I am one of these folks who like to work with fruity examples:
$ django-admin.py startproject fruitsalad $ cd fruitsalad/ $ django-admin.py startapp fruits
This should generate something that looks like this:
$ find . ./manage.py ./fruits ./fruits/views.py ./fruits/tests.py ./fruits/models.py ./fruits/__init__.py ./__init__.py ./settings.py ./urls.py
Set up the basic database information in the settings.py:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'fruit.db',
}
}
Make manage.py executable
$ chmod u+x manage.py
and fire up the server:
$ ./manage.py runserver Validating models... 0 errors found Django version 1.2.1, using settings 'fruitsalad.settings' Development server is running at http://127.0.0.1:8000/ Quit the server with CONTROL-C.
If you open http://127.0.0.1:8000/ in your browser you should get a welcome page.
Install nose and django-nose:
$ sudo pip install nose $ sudo pip install django-nose
In the settings.py we need to tell the Django project to load our app and django-nose:
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'fruitsalad.fruits', # Add this
'django_nose', # Add this
)
Additionally we have to specify nose as our test runner:
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
Testing models
Test drive development means to write first the test and then the functionality to fulfill the expectations. So let's write the first unit tests with nose. For nose is does not matter where in project folder you put the tests. It will find them (if you name the classes and functions correctly - i.e. starting with "Test" or "test", respectively) but when creating the fruits app the file fruits/tests.py was created. I think it's a good location for our tests so I will use it. django-admin.py put a unittest and a docstring based test example into this file already but we want to use nose syntax for our tests. We copy the following text into the file:
from fruitsalad.fruits.models import Fruit
import nose.tools as nt
class TestFruit(object):
def setup(self):
self.fruit = Fruit()
self.fruit.set_name("Papaya")
self.fruit.set_color("orange")
def test_color(self):
nt.assert_equal(self.fruit.name, "Papaya")
nt.assert_equal(self.fruit.color, "orange")
def test_yumminess(self):
nt.assert_true(self.fruit.is_yummy())
def test_color_change(self):
self.fruit.become_brown()
nt.assert_equal(self.fruit.color, "brown")
def teardown(self):
self.fruit.disappear()
Here we have one test class that contains three test functions. nose offers different possibilities to compare the expectations with real results. In the example we use assert_equal (the two given argument have to be equal) and assert_true (the given argument has to be True) but there are many others. Check pydoc nose.tools or this page for further references. We also use the setup and the teardown method to prepare our testing environment before the tests and to clean it after the tests.
We can now run nose and see if it test fails. As we configured nose as test runner we can do this in the following way:
$ ./manage.py test
Now we have our tests which define our expected behaviour of the model. We continue by creating a model that satisfies these tests. We add the following to fruits/model.py:
from django.db import models
class Fruit(models.Model):
name = models.CharField(max_length=50)
color = models.CharField(max_length=50)
def set_name(self, name):
self.name = name
def set_color(self, color):
self.color = color
def is_yummy(self):
return(True)
def become_brown(self):
self.color = "brown"
def disappear(self):
self.color = "transparent"
Now it should be possible to run the tests successfully:
$ ./manage.py test Creating test database 'default'... Creating table auth_permission Creating table auth_group_permissions Creating table auth_group Creating table auth_user_user_permissions Creating table auth_user_groups Creating table auth_user Creating table auth_message Creating table django_content_type Creating table django_session Creating table django_site Creating table fruits_fruit Installing index for auth.Permission model Installing index for auth.Group_permissions model Installing index for auth.User_user_permissions model Installing index for auth.User_groups model Installing index for auth.Message model No fixtures found. nosetests --verbosity 1 ... ---------------------------------------------------------------------- Ran 3 tests in 0.002s OK Destroying test database 'default'...
If a test fails you get a message like this:
[...]
File "/home/myuser/fruitsalad/../fruitsalad/fruits/tests.py", line 12, in test_color
nt.assert_equal(self.fruit.name, "Kiwi")
AssertionError: 'Papaya' != 'Kiwi'
----------------------------------------------------------------------
Ran 6 tests in 0.012s
FAILED (failures=1)
Destroying test database 'default'...
Testing views
In the next step we are going to test the views of our app (if you are not a Djangonout so far: the expression "view" is used differently as in other web frameworks). This could be already see as integration testing as interaction between model and views could be tested here. But to keep it simple for this introduction we don't use our models here but only let the views do the work. To access the views we use Django's Client class. We add the following test to our fruits/test.py file:
from django.test import Client
class TestFruitView(object):
def setup(self):
self.client = Client()
def test_product_index(self):
response = self.client.get("/fruits/")
nt.assert_equal(response.content, "The index")
def test_product_show(self):
response = self.client.get("/fruits/papaya")
nt.assert_equal(response.content, "Show the papaya page")
def test_product_add(self):
response = self.client.get("/fruits/add")
nt.assert_equal(response.content, "Add a fruit")
A test run might return this now:
TemplateDoesNotExist: 404.html ---------------------------------------------------------------------- Ran 6 tests in 0.019s FAILED (errors=3) Destroying test database 'default'...
The first step to solve this is to specify our url pattern in the file url.py:
urlpatterns = patterns(
'',
(r'^fruits/add', 'fruits.views.add'),
(r'^fruits/(\w+)', 'fruitsalad.fruits.views.show'),
(r'^fruits/', 'fruitsalad.fruits.views.index')
)
Now our app knows which view should be accessed depending on the requested url. Still we get errors:
TemplateDoesNotExist: 404.html ---------------------------------------------------------------------- Ran 6 tests in 0.020s FAILED (errors=3) Destroying test database 'default'...
This is due to the fact that the views don't exits so far - what we will change now. We adapt the fruits/views.py to look like this:
from django.http import HttpResponse
def index(request):
return HttpResponse("The index")
def show(request, fruit):
return HttpResponse("Show the %s page" % fruit)
def add(request):
return HttpResponse("Add a fruit")
Now the new tests run successfully.
Destroying test database 'default'... kuf@yersinia $ ./manage.py test Creating test database 'default'... Creating table auth_permission Creating table auth_group_permissions Creating table auth_group Creating table auth_user_user_permissions Creating table auth_user_groups Creating table auth_user Creating table auth_message Creating table django_content_type Creating table django_session Creating table django_site Creating table fruits_fruit Installing index for auth.Permission model Installing index for auth.Group_permissions model Installing index for auth.User_user_permissions model Installing index for auth.User_groups model Installing index for auth.Message model No fixtures found. nosetests --verbosity 1 ...... ---------------------------------------------------------------------- Ran 6 tests in 0.013s OK Destroying test database 'default'...
I hope you got a basic understanding how to test your Django app with nose. In the second part we will dive into the testing of our project with lettuce.



