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

14 February 2009

Starting a project with Maven 2, Subversion, Hudson, and Restlet



First, install maven 2 and subversion as appropriate for your platform. Navigate to where you want to work on this tutorial, then run a command like

mvn archetype:create -DgroupId=com.mycompany.app -DartifactId=superapp

That might take a few minutes. After running it, you will have a directory called superapp in your current directory.

cd superapp

You can verify everything worked by running the following commands:

mvn package

If you lack a jdk, the first command will say BUILD FAILURE and explain where it looked. In that case, install a jdk. This command might also take a little while, since it must download additional jars.

Then,

java -cp target/superapp-1.0-SNAPSHOT.jar com.mycompany.app.App

Will print "Hello World!"

So far this has all been duplicating the basic maven tutorial.

Type mvn clean to get rid of the compiled files, since we'll be putting this in version control.

Now we're going to take this simple app, integrate it into version control and a continuous integration system, and give it some small functionality.

First, we'll add it to a subversion repository. If you don't already have one handy to use, you'll want to create one with the appropriate structure. Move back to your working directory (that is, below the superapp directory), then type:

svnadmin create superapp-repository


mkdir superapp-structure
mv superapp superapp-structure/trunk
mkdir superapp-structure/branches
mkdir superapp-structure/tags
svn import superapp-structure file:///full/path/to/superapp-repository/superapp -m "First import."

Then, to get a copy you can work with, run

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

If you want, cd into that directory and verify you can still package and run the app.

Now we're going to start continuous integration. Download the latest release at hudson.dev.java.net

It will be a war file -- make sure you don't unpack it.

Open a new terminal and move to wherever you downloaded it, then run

java -jar hudson.war

That will start up a instance of hudson we can use for this tutorial, available at http://localhost:8080/

When you go there, you should see the Hudson dashboard, which says Welcome to Hudson! in the middle.

Click on New Job, on the left.

Name the job "Superapp Build" and choose "Build a maven2 project". This job will be just for building superapp. Under Source Code Management, choose Subversion, then put file:///full/path/to/superapp-repository/superapp/trunk for the Repository URL. Leave everything else at the defaults.

Leave Build whenever a SNAPSHOT dependency is built checked, and also check Poll SCM. For Schedule, go ahead and put "* * * * *" (without quotation marks). That will poll every minute.

Next to maven version, click system configuration link. Down where it says Maven you'll need to Add a Maven installation. I called it "default maven" and gave it the appropriate maven home. On my system, that was /usr/share/maven2 . You'll know you have the location right if, when your focus leaves the MAVEN_HOME field on the hudson config page, it doesn't complain.

After doing that, go back to the Superapp Build config page, put 'package' (without quotation marks) for Goals and option, then save. At this point, Hudson should build the project for the first time. You'll be able to see that the build has succeeded on the "Superapp Build" page. Go ahead and click on the link to the build. There will also automatically be a test report generated, which you can peruse by clicking on Test Result on the left.

Now that we have basic continuous integration going, lets go back to our superapp directory with the code checked out from the repository. We're going to edit that.

Since I'm going to edit in eclipse, I first need to turn it into an eclipse project, so I run


mvn eclipse:eclipse
svn add .classpath .project
svn commit -m "Added eclipse support"


Then check out the project in eclipse to edit. You could also just edit the project in place. If you decide to edit in eclipse, make sure you install the Maven 2 eclipse plugin.

We're going to make this a Restlet application, so first we're going to add a dependency on Restlet. Go ahead and add this section to the <repositories> element of the pom.xml (you can also do this with m2eclipse):

<repository>
<id>maven-restlet</id>
<name>Public online Restlet repository</name>
<url>http://maven.restlet.org</url>
</repository>

Then add the following chunk to the <dependencies> element:


<dependency>
<groupid>org.restlet</groupid>
<artifactid>org.restlet</artifactid>
<version>1.1.2</version>
</dependency>
<dependency>
<groupid>com.noelios.restlet</groupid>
<artifactid>com.noelios.restlet</artifactid>
<version>1.1.2</version>
</dependency>
<dependency>
<groupid>com.noelios.restlet</groupid>
<artifactid>com.noelios.restlet.ext.simple</artifactid>
<version>1.1.2</version>
</dependency>

Now, in the com.mycompany.app package, go ahead and create the HelloWorldResource and FirstStepsApplication classes as outlined at restlet.org

Then, replace the main method in the App class with the one from the "Run as a standalone Java application" section.

You can verify this worked by running the new main method and visiting http://localhost:8182 , where you should see your hello world message.

Now, add and commit your changes. This will trigger a hudson build, of course. If the build fails, you probably need to add the following section just inside the <project> element of your pom.xml, telling maven to use java 1.5:


<build>
<plugins>
<plugin>
<groupid>org.apache.maven.plugins</groupid>
<artifactid>maven-compiler-plugin</artifactid>
<configuration>
<source>1.5</source>
<target>1.5</target>
</configuration>
</plugin>
</plugins>
</build>

Now, lets add a few tests. But first, go ahead and change the version of junit in the pom.xml to 4.5 . Then add a new junit test case in the tests source folder and appropriate package called HelloWorldResourceTest.

Time to start testing. Fill out our test case like so:


public class HelloWorldResourceTest {

private HelloWorldResource resource;

@Before
public void createResource() {
Request request = new Request();
this.resource = new HelloWorldResource(new Context(), request, new Response(request));
}

@Test
public void testConstructor() {
assertTrue(false);
}

@Test
public void testRepresent() {
assertTrue(false);
}
}

Check those changes in, and see how hudson responds. Notice that the old test is still passing, even though it isn't testing anything. You might want to remove that old test case for now, especially as we've moved to junit 4.5 .

Lets make those tests pass.

First, the constructor test:

@Test
public void testConstructor() {
assertEquals(1, this.resource.getVariants().size());
assertEquals(MediaType.TEXT_PLAIN, this.resource.getVariants().get(0).getMediaType());
}

Then, the represent test:

@Test
public void testRepresent() throws IOException {
Representation representation = this.resource.represent(new Variant(MediaType.TEXT_PLAIN));
assertEquals("Hello World!", representation.getText());
}

Take a look at how that is reflected in Hudson.

At this point it would be possible to make successful builds be deployed to a development environment, but that's a task for another time.