23 February 2009

Django and Hudson and Bears, Oh My! (and twill, and figleaf)

Creating a django project with unit tests and continuous integration.

Prerequisites:

Django (make sure django-admin.py is in your PATH.)
Subversion (at the very least some repository you can use. This tutorial will guide you through creating a local repository.) If you go all the way through the tutorial you'll need Twill and Figleaf as well (both can be installed with easy_install).

During the tutorial we'll be downloading and running a copy of Hudson with its built in server as a demonstration. In production you'll probably want to run Hudson in Tomcat or another servlet container.

NOTE: While the song names are real, the metadata is contrived.

First, create a django project with an app inside of it:


django-admin.py startproject reinhardt
cd reinhardt
python manage.py startapp catalog
cd ..


Then we'll create a repository and move it into version control (feel free to put the project in whatever subversion space you have handy. The code below will create a local repository and import the app).


svnadmin create reinhardt-repository

mkdir reinhardt-structure
mkdir reinhardt-structure/tags
mkdir reinhardt-structure/branches
mv reinhardt reinhardt-structure/trunk

svn import reinhardt-structure file:///full/path/to/reinhardt-repository/reinhardt -m "first import"



Now that we have our code in version control, go ahead and do:

svn co file:///full/path/to/reinhardt-repository/reinhardt/trunk reinhardt

and you can delete reinhardt-structure.

We're not going to flesh out the application very much, but we want a little code and some tests.

Before we can do that, we need to set up the settings.py

Change DATABASE_ENGINE to 'sqlite3', since we'll be using a testing database only.

Change DATABASE_NAME to be 'development_db' -- this file is going to be created when you do certain things, but make sure you don't add it to version control.

Down in the INSTALLED_APPS setting, add 'django.contrib.admin' and 'reinhardt.catalog' to the list.

That's all the settings changes we'll make at the moment, go ahead and commit them.

Now we'll add a little code to make this slightly more like a real application.

Edit reinhardt/catalog/models.py to look like this:


from django.db import models

from math import floor


class Song(models.Model):
title = models.CharField(max_length=200)
length_in_seconds = models.IntegerField()

def _length(self):
minutes = floor(self.length_in_seconds / 60)
seconds = self.length_in_seconds % 60
return "%d:%d" % (minutes, seconds)

length = property(_length)

def __unicode__(self):
return "%s (%s)" % (self.title, self.length)

class Single(models.Model):
song = models.ForeignKey(Song, related_name='singles')
released = models.DateField()

def __unicode__(self):
released = self.released.strftime('%d %b %Y')
return "Single of %s, released %s" % (self.song,released)

def related_albums(self):
return Album.objects.filter(song=self.song)

def related_singles(self):
return Single.objects.filter(song=self.song)

class Album(models.Model):
title = models.CharField(max_length=200)
songs = models.ManyToManyField(Song, related_name='albums')
released = models.DateField()

def __unicode__(self):
released = self.released.strftime('%d %b %Y')
return "%s, released %s" % (self.title, released)

def related_albums(self):
return Album.objects.filter(song__in=self.songs.all())

def related_singles(self):
return Single.objects.filter(song__in=self.songs.all())



Now we have a model. If you're familiar with django, feel free to play with it a little, but learning how to work with django models isn't the point of this tutorial. This model is just a toy model for demonstrating some practical aspects of testing. Additionally, there are a few errors in it that we'll be fixing as we go along, using tests.

Time to add tests. We will add doctests, then unit tests.

To start with, create a simple doctest at the top of the catalog module.


"""Home to our models for catalogs, where songs are connected to singles and albums.

Use is simple:

>>> from datetime import date
>>> song = Song(title="Puff the Magic Dragon", length_in_seconds = 753)
>>> single = Single(song=song, released=date(1975, 11, 15))
>>> single.song.title
"Puff the Magic Dragon"

"""


Then run the test with python manage.py test catalog. I encourage you to verify that this is really a working test by changing the expected song name to something other than "Puff the Magic Dragon" and re-running the test.

Now expand that a little, to


"""Home to our models for catalogs, where songs are connected to singles and albums.

Use is simple:

>>> import datetime
>>> song = Song(title="Puff the Magic Dragon", length_in_seconds = 753)
>>> song.save()
>>> single = Single(song=song, released=datetime.date(1975, 11, 15))
>>> single.save()
>>> single.song.title
"Puff the Magic Dragon"
>>> album = Album(title="Random Compilation", released=datetime.date(1978, 2, 23))
>>> album.save()
>>> album.songs.add(song)
>>> album.related_singles()
[<Single: Single of Puff the Magic Dragon (12:33), released 15 Nov 1975>]
>>> single.related_albums()
[<Album: Random Compilation, released 23 Feb 1978>]

"""


If you run this, you'll receive an exception. We've found our first bug to fix. If you examine the error, you'll quickly realize the problem is when we call filter in Single.related_albums, and that's because it can't find the attribute song on the Album objects. It gives the possible options: obviously, we want "songs".

With that function changed to be

def related_albums(self):
return Album.objects.filter(songs=self.song)

, our first doctest passes. Now we'll add simple doctests to each of the model classes. These won't uncover any more problems, but they're good for reassuring us of basic functionality, as well as providing examples to people reading the source code.

At the end, our file looks like this:

"""Home to our models for catalogs, where songs are connected to singles and albums.

Use is simple:

>>> import datetime
>>> song = Song(title="Puff the Magic Dragon", length_in_seconds = 753)
>>> song.save()
>>> single = Single(song=song, released=datetime.date(1975, 11, 15))
>>> single.save()
>>> single.song.title
"Puff the Magic Dragon"
>>> album = Album(title="Random Compilation", released=datetime.date(1978, 2, 23))
>>> album.save()
>>> album.songs.add(song)
>>> album.related_singles()
[<Single: Single of Puff the Magic Dragon (12:33), released 15 Nov 1975>]
>>> single.related_albums()
[<Album: Random Compilation, released 23 Feb 1978>]

"""


from django.db import models

from math import floor


class Song(models.Model):
"""Represents a particular rendition of a song.

>>> song = Song(title="Jumpin' Jack Flash", length_in_seconds=32)
>>> song.title
"Jumpin' Jack Flash"
>>> song.length
"0:32"

"""
title = models.CharField(max_length=200)
length_in_seconds = models.IntegerField()

def _length(self):
minutes = floor(self.length_in_seconds / 60)
seconds = self.length_in_seconds % 60
return "%d:%d" % (minutes, seconds)

length = property(_length)

def __unicode__(self):
return "%s (%s)" % (self.title, self.length)

class Single(models.Model):
"""A one-song release.

>>> import datetime
>>> song = Song(title="Jumpin' Jack Flash", length_in_seconds=32)
>>> single = Single(song=song, released=datetime.date(1968, 11, 1))
>>> single.song.title
"Jumpin' Jack Flash"
>>> single
<Single: Single of Jumpin' Jack Flash (0:32), released 01 Nov 1968>

"""
song = models.ForeignKey(Song, related_name='singles')
released = models.DateField()

def __unicode__(self):
return "Single of %s, released %s" % (self.song, self.released.strftime('%d %b %Y'))

def related_albums(self):
return Album.objects.filter(songs=self.song)

def related_singles(self):
return Single.objects.filter(song=self.song)

class Album(models.Model):
"""A one-song release.

>>> import datetime
>>> album = Album(title="Random Stuff", released=datetime.date(2020, 5, 8))
>>> album
<Album: Random Stuff, released 08 May 2020>

"""
title = models.CharField(max_length=200)
songs = models.ManyToManyField(Song, related_name='albums')
released = models.DateField()

def __unicode__(self):
return "%s, released %s" % (self.title, self.released.strftime('%d %b %Y'))

def related_albums(self):
return Album.objects.filter(song__in=self.songs.all())

def related_singles(self):
return Single.objects.filter(song__in=self.songs.all())

Go ahead and check that all in.

Okay, time to get this working with Hudson. First, download Hudson from http://hudson.gotdns.com/latest/hudson.war , then run it with java -jar hudson.war

You can visit it at http://localhost:8080/

Click on New Job, on the left. Name it something like "reinhardt-test" and choose the "Build a free-style software project" option.

Choose Subversion for source code management, and put in file:///full/path/to/reinhardt-repository/reinhardt/trunk for the repository URL, with "reinhardt" for the local module directory.

Check Poll SCM, and put * * * * * for the schedule to make it check every minute.

In the "Add Build Step" dropdown, choose "Execute shell". There, put python reinhardt/manage.py test catalog.

Finally, towards the bottom check "Publish JUnit test result report" and for now put *.xml -- obviously we're not using JUnit, but that's how Hudson will think of our tests. That won't work right away, though.

Then save the configuration.

After it builds, click on the build and then take a look at the console output. You should see that the tests run successfully, but at the end some lines like

No test report files were found. Configuration error?
finished: FAILURE

That's because there are no JUnit XML files around. We'll change that. Install the python library unittest-xml-reporting ( http://github.com/danielfm/unittest-xml-reporting/tree/master ) with easy-install. Then, edit the settings.py file to have the lines (don't forget to commit)

TEST_RUNNER = 'xmlrunner.extra.djangotestrunner.run_tests'
TEST_OUTPUT_DESCRIPTIONS = True

The second isn't strictly necessary, but it will make the test names prettier for our doctests (it triggers a setting in the custom test runner). (NOTE: the prettier names didn't seem to work for me, but ah well).

Run the build now and it should succeed.

Now I'll show you how to start writing unit tests for django, then let you finish them up (there's one or two more bugs). After those are working, we're going to give the website a (very simple) public face and add some higher-level tests that use a framework called twill. We'll also take a look at code coverage with figleaf.

Create a file called tests.py in reinhardt/catalog/ . For a first test, put the following in the file:

from django.test import TestCase

from reinhardt.catalog.models import *

class SongTestCase(TestCase):
fixtures = ['songs.json']

def testLength(self):
song = Song.objects.get(title="Ice Ice Baby")
self.assertEquals(song.length, "0:07")


Notice the line with 'fixtures'. That line will tell Django to load certain data in its database. We will need to generate that file. Initially, we'll just put the song we're using in it. You'll want to add items as needed for your other tests.

To create the file, delete the current development_db in your directory, then run python manage.py syncdb. Now, run python manage.py shell

In there, run the following commands:


from reinhardt.catalog.models import Song
song = Song(title="Ice Ice Baby", length_in_seconds=7)
song.save()


Then exit the shell, create a directory at reinhardt/catalog/fixtures, and run python manage.py dumpdata --format=json catalog > catalog/fixtures/songs.json

Now try running your tests again. That one will fail, because the song length is not being formatted exactly as desired. Fix that by tweaking the formatting string in the _length method.

You should add more tests at this point, along with necessary fixture data. Make sure you fix any additional errors you find.

Commit when you're ready to move on (or before if you want to see how things are reflected in Hudson).

Now we're going to add a very simple view to be tested, a list of songs. First, edit urls.py so the url "songs/" points at reinhardt.catalog.views.list_songs , then put the following in catalog/views.py :


from django.http import HttpResponse

from reinhardt.catalog.models import Song


def list_songs(self):
songs = Song.objects.all()
html = u"<html><body><ul>"
for song in songs:
html += u"<li>%s</li>" % song
html += "</ul></body></html>"
return HttpResponse(html)



Obviously atrocious django programming style, but sufficient for demonstrating the functionality we're interested in.

Time to add tests for the view. Add the following to the fixture in tests.py:



def testListSongs(request):
response = self.client.get('/songs/')
self.assertTrue("Ice Ice Baby" in response.content)
self.assertEquals(response.status_code, 200)


That should succeed right off. Go ahead and commit. This wraps up all the types of testing we can handily do without actually running the application as a server.

Time to take the next step. Install twill with easy_install, and we will use it to test the website as viewed through an http client.

Add this test to the fixture in tests.py:


def testListSongsHttp(self):
from django.core import management
import twill.unit
PORT = 8765

def run_server():
management.call_command('testserver', 'songs.json', addrport=str(PORT))

test_info = twill.unit.TestInfo('list_songs.twill', run_server, PORT, sleep=3)

twill.unit.run_test(test_info)



Then, create the file 'list_songs.twill' in reinhardt and give it the following content:


go /songs/
code 200
find "Ice Ice Baby"



That's just testing the things we were testing with django's tests, but verifying that the server is actually serving them to http clients. The real strength of twill will come in testing workflows through multiple forms and the like.

Note: if you commit this, the tests from Hudson will fail because of the relative path on the .twill file. The easiest way to fix that is to add a cd reinhardt before the command in the configuration (tweaking the rest of the command and the location of the tests as necessary for the new location).

And there you have it, testing all the way through to the level of http and html.

If you want to see how easy it is to add code coverage from here, easy_install figleaf, then make these changes to the configuration:

Change the command to cd reinhardt; figleaf -i manage.py test catalog; figleaf2html. Then look in the workspace for the project. Inside reinhardt/html/ you'll find index.html, which gives the appropriate coverage reports (the exact list of files covered could be fine-tuned, but it is pretty good to start with).

1 comment:

  1. This is an absolutely awesome walkthrough. Thank you for posting it! If you've updated this at all with new techniques or ideas it would be great to see a followup post!

    ReplyDelete