Testing and Django

by Matthew Snider · June 11, 2011 @ 2:24 p.m.


This article continues last weeks series on building a continuous deployment environment using Python and Django.

  • Starting Your First Django Project
  • Testing and Django
  • Mock and Coverage
  • Using Fabric for Painless Scripting
  • Using Celery to handle Asynchronous Processes
  • Deployment/Monitoring Strategies

Before we discuss testing in Django, lets define what makes up good testing infrastructure.

There are many schools of thoughts on testing, but that's another article. Ignoring the different testing philosophies, I've always broken my tests into 3 basic types: unit, functional, and non-functional testing. At the very least one should be able to test every function, testing the response of a function when different variable combinations are provided (unit testing). When building websites, one also needs a client unit test that evaluates site pages by executing the controller code for a specific request and evaluating the view response (treat these like unit tests). An extension of unit testing is functional testing, where you test a business objective from end-to-end, such as BAT (basic acceptance test), user sign in, or purchasing goods. The rest falls into non-functional testing, which covers events that are not related to business objectives, but may be equally important, such as scalability and security.

From the above you can make a test plan that covers every function and view in your code, and any critical business objectives or support processes. However, in addition to a basic testing harness, we will need at least three more tools: mock data, test coverage, and a tool for mocking objects/functions. Mock data is a collection of known data to load before a test suite/case, and usually unload afterwards. Test coverage will measure how much of your code is executed by the tests. This will help evaluate the completeness of your test harness. A mocking tool will allow testing around services outside of the app being tested, without actually calling those services. The mocking tool will replace the service call and return specified data instead, allowing for testing of known good and bad responses.

We will be discussing all concepts mentioned above today, except coverage and the mocking tool, which will be covered in the next article. With these concepts in mind, lets look at what Django and Python provide for building your testing system.

Getting ready

Django looks for tests.py files in each of your apps registered in settings.py and executes the TestCase classes in them. Django runs tests in the order it loads them, but unloads any data written by one test before executing another (except when modifying initial test data), so test cases should always be written independent of each other. This ensures that tests will pass, regardless of the order, while allowing for multithreading, as your test suite grows.

If you were following the instructions from Tips for Starting Your First Django Project then you already have everything setup. Otherwise, follow the steps to create a project and start the myapp1 app.

How to do it…

Django will have generated a tests.py file that contains:

"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".

Replace this with more appropriate tests for your application.
"""

from django.test import TestCase

class SimpleTest(TestCase):
    def test_basic_addition(self):
        """
        Tests that 1 + 1 always equals 2.
        """
        self.assertEqual(1 + 1, 2)


I recommend writing a TestCase per file in an app, so something like:

class BaseTestCase(TestCase):
	…

class ModelsTestCase(TestCase):
	…

class ViewsTestCase(TestCase):
	…
	
etc…

The Django test runner will look at each TestCase instance and execute the functions starting with test. Before each test function runs, it will load any mock data and run the setUp function (if defined). After each test, it will drop any data loaded before and during then test function, then run the tearDown function (if defined).

Test Data

Test data in Django is called a fixture and can be used to load static data, such as geographical locations, or dynamic data, such as test users. Fixtures are useful when you need data in your app for the tests to run or you setup users with specific conditions that will target different parts of the code.

Fixtures should be stored in the myapp1/fixtures directory. If the fixture is called initial_data.json then it will be automatically when the test runner starts. You may also define fixtures that need to be loaded before each test in a TestCase by defining the fixtures attribute:

class BaseTest(TestCase):
	fixtures = ['myapp1_fixture1', 'myapp1_fixture2']
	…


Django will look for myapp1_fixture1.json and myapp1_fixture2.json in the fixtures directory of all apps, so make sure the names are unique accross all apps (I do this my prefixing file names with the name of the app).

When creating fixtures, use the command line to create a fixture from the database:

python manage.py dumpdata myapp1 > myapp1/fixtures/myapp1_testdata.json

Here is an example fixture for the Django auth user:

[
	{
		"pk": 1,
		"model": "auth.user",
		"fields": {
			"username": "completed_reg",
			"first_name": "Matt",
			"last_name": "Snider",
			"email": "[email protected]",
			"password": "sha1$e66d8$c994203a282b2ebff8330a690378908b71c8c1c2",
			"is_staff": 0,
			"is_active": 1,
			"is_superuser": 0,
			"last_login": "2010-09-07 19:52:39",
			"date_joined": "2010-09-07 19:52:39"
		}
	}
]

All JSON fixtures are an array of objects, with at least the pk, model, and some fields defined. Whenever possible, I try to use one of the fields to make the fixture data descriptive as to its purpose, such as the username in this example.

For more information on fixtures and different data formats, see Initial Data.

Assertions

The core to any testing infrastructure is the ability to make assertions about the code. The Django TestCase class extends the python ut2.TestCase class, which has all the basic assertions:

from django.test import TestCase

class BaseTest(TestCase):

	def test_some_things(self):
		# these assertions have been in python for a while
		self.assertTrue(1) # evaluates truthy-ness
		self.assertFalse(0) # evaluates falsy-ness
		self.assertEquals(1, 1) # evaluates second (dynamic) value equals the first (known) value;
		self.assertNotEquals(1, 2)
		
		# some of the new assertions added in python 2.7
		o = 1
		self.assertIs(o, o) # assert evaluate to the same object
		self.assertIn(1, [1, 2, 3]) # assert first value in second
		self.assertIsInstance(self, TestCase) # first value is instance of second

For the full documentation see, the Python Documentation 25.3. unittest – Unit testing framework.

Django also adds new assertions for evaluating views and other Django specific tasks:

from django.test import TestCase

class BaseTest(TestCase):

	def test_some_things(self):
		self.assertRedirects(response, expected_url, status_code, target_status_code)
		self.assertContains(response, text, count, status_code)
		self.assertNotContains(response, text, count, status_code)
		self.assertFormError(response, form, field, errors)
		self.assertTemplateUsed(response, template_name)
		self.assertTemplateNotUsed(response, template_name)
		self.assertQuerysetEqual(qs, values)


Most of these evaluate responses from the Client class, which emulates requests so you can test views. Additionally, you can use assertQuerysetEqual to examine Django query sets. See the Django Assertion, for more information on how these tests work.

Testing Views

While basic testing is straight forward, lets dive into view testing, as it is a little more complex. If we setup the following crud urls, views, and tests for a simple comment app:

/urls.py

…
    (r'^comments/', include('comments.urls', namespace='comments')),
…

comments/urls.py

from django.conf.urls.defaults import *

urlpatterns = patterns('comments.views',
        url(r'^create/(?P\d+)/$', 'create', name='create'),
        url(r'^delete/(?P\d+)/$', 'delete', name='delete'),
        url(r'^(?P\d+)/$', 'read', name='read'),
        url(r'^update/(?P\d+)/$', 'update', name='update'),
)

comments/views.py

 # imports not included, comments/models.py and comments/forms.py assumed
def create(request, comment_id=None):
    if 'POST' == request.method:
    	form = CommentForm(request.POST)
    	
    	if form.is_valid():
    		cleaned_data = form.cleaned_data
    		
    		comment = Comment.objects.create(user=request.user, comment=cleaned_data.get('comment', ''))
    		return redirect('comments:read', comment_id=comment.id)
    
    if comment_id: # update
    	comment = get_object_or_404(Comment, id=comment_id)
    	form = CommentForm(comment=comment.comment)
    else: # create
	    form = CommentForm()

    ctx = {
        'comment': comment,
        'form': form,
    }
    return render_to_response('comments_create.html', ctx, context_instance=RequestContext(request))
    
@login_required
def delete(request, comment_id):
    comment = get_object_or_404(Comment, id=comment_id)
    user = request.user

    # assert logged in user is comment owner
    if not (user and user.is_authenticated() and user.pk = comment.user_id):
    	raise Http404
    
    return redirect('comments:add')
    
def read(request, comment_id):
    comment = get_object_or_404(Comment, id=comment_id)

    ctx = {
        'comment': comment,
    }
    return render_to_response('comments_read.html', ctx, context_instance=RequestContext(request))

comments/tests.py

from django.test import TestCase
from django.test.client import Client

class CommentViewTestCase(TestCase):
    def setUp(self):
        super(CommentViewTestCase, self).setUp()
        self.client = Client()
        self._i = 1
        
    def _create_user(self) {
    	'''Create simple, throw-away users'''
    	user = User.objects.create(
    		first_name='first_name%d' % self._i,
    		last_name='last_name%d' % self._i,
    		email='test%[email protected]' % self._i,
    		username='test%d' % self._i,
    	)
    	self._i += 1
    	return user
    }
        
    def test_create_and_update(self):
    	# too complex for this example
    	pass
        
    def test_delete(self):
    	view_name = 'contacts:delete'
    	
    	# comment doesn't exist, should raise 404
        url = reverse(view_name, kwargs={'comment_id': 1})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 404)
        
        # create two test users and a comment
        user1 = self._create_user()
        user2 = self._create_user()
        comment_text = 'test'
        comment = Comment.objects.create(comment=comment_text, user=user1)
    	
    	# non-logged in user cannot delete a comment
        url = reverse(view_name, kwargs={'comment_id': comment_id})
        response = self.client.get(url)
        self.assertRedirects(response, reverse('signin'), 302, 200)
    	
    	# non-owning user cannot delete a comment
    	self.client.login_user(user2)
        url = reverse(view_name, kwargs={'comment_id': comment_id})
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)
    	
    	# owning user can delete a comment
    	self.client.login_user(user1)
        url = reverse(view_name, kwargs={'comment_id': comment_id})
        response = self.client.get(url)
        self.assertContains(response, comment_text, 1)
        
    def test_page(self):
    	view_name = 'contacts:read'
    	
    	# comment doesn't exist, should raise 404
        url = reverse(view_name, kwargs={'comment_id': 1})
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)
        
        # create test user and comment
        user = self._create_user()
        comment_text = 'test'
        comment = Comment.objects.create(comment=comment_text, user=user)
    	
    	# non-logged in user can view page
        url = reverse(view_name, kwargs={'comment_id': comment_id})
        response = self.client.get(url)
        self.assertContains(response, comment_text, 1)
    	
    	# logged in user can view page
    	self.client.login_user(user)
        url = reverse(view_name, kwargs={'comment_id': comment_id})
        response = self.client.get(url)
        self.assertContains(response, comment_text, 1)

This example shows how to setup the urls, build some simple crud views, and test them. It is important to test against which users can access and edit content. Additionally, we assert that the comment is in the response text when testing the comment page, and we assert that the delete redirects to the sign in (assumed to be defined) page when the user is not logged in.

Running Tests

Tests are simple to run from the command line. To test a single app use:

python manage.py test myapp1

To test several apps use:

python manage.py test myapp1 myapp2 myapp3 …

To test all apps, including Django apps, use:

python manage.py test

How it works…

The Django TestRunner does the following: sets up the Django environment, builds the test suite from all the test cases in the included apps, connects to the database and creates test tables using syncdb, runs the test suite, tears down the database and environment, then reports the results. The TestRunner will report the number of tests run, which ones fail or raise exceptions, and will show the exceptions. When running via a script for continuous deployment, check the stderr output for failures.

Fixtures can drastically affect the performance of your tests, as fixtures defined on a test case are loaded and unloaded between each test. Use initial_data.json (can have 1 per app) to load static data that should be shared between all apps. However, one should generally try to keep fixtures as small as possible.

There’s more…

For more general information on tests, see Testing Django applications.

One way you can increase the performance of your tests is to use the SQLite DB for testing, instead of an SQL DB. Use the following setting to detect if you are running a test or not, and to setup your DB options:

from sys import argv
if argv and 1 < len(argv):
    IS_TEST = 'test' == argv[1]
else:
    IS_TEST = False

if IS_TEST:
    DB_ENGINE = 'django.db.backends.sqlite3'
ELSE:
	DB_ENGINE = 'django.db.backends.mysql'
	
DATABASES = {
    'default': {
        'NAME': DB_NAME,
        'ENGINE': DB_ENGINE,
        'USER': DB_USER,
        'PASSWORD': DB_PASSWORD,
        'HOST': DB_HOST,
        'OPTIONS': DB_OPTIONS,
    },
}

As you write more tests, common patterns begin to arise, such as a helper function for creating disposable users (shown in the view example). I recommend defining a common TestCase class as a basis for your other tests:

from django.test import TestCase

class TestCaseBase(TestCase):

    def setUp(self):
        super(TestCaseBase, self).setUp()
        self._user_counter = 1

    def _create_user(self):
        first_name = 'delete'
        last_name = 'me'
        i = self._user_counter
        username = '%s%s%[email protected]' % (first_name, last_name, i)
        self._user_counter += 1

        user = User.objects.create(
            email='%[email protected]' % username,
            username=username,
            first_name=first_name,
            last_name=last_name,
        )

        self._created_users.append(user)
        return user

Lastly, if you use signals and myapp1 listens for model changes in myapp2, then when you are testing myapp2 those listeners will fire. This tends to be undesired as you want to test each app in isolation of each other. In this case myapp1 should be testing those listeners, not myapp2, which shouldn’t need to know what other apps listen for its model changes.

Fortunately, Django allows you to temporarily turn off some or all signal listeners. At Votizen.com, we have abstracted this logic away for testing purposes, so we can off all or just some signals:

class NoSignalTestCase(TestCaseBase):
    def _receiver_in_lookup_keys(self, receiver, lookup_keys):
        """
        Evaluate if the receiver is in the provided lookup_keys; instantly terminates when found.
        """
        for key in lookup_keys:
#           print '%s - %s - %s' % (key[0], receiver[0][1], key[1])
            if (receiver[0][0] == key[0] or key[0] is None) and receiver[0][1] == key[1]:
                return True
        return False

    def _find_allowed_receivers(self, receivers, lookup_keys):
        """
        Searches the receivers, keeping any that have a lookup_key in the lookup_keys list
        """
        kept_receivers = []
        for receiver in receivers:
            if self._receiver_in_lookup_keys(receiver, lookup_keys):
                kept_receivers.append(receiver)
        return kept_receivers

    def _create_lookup_keys(self, sender_receivers_tuple_list):
        """
        Creates a signal lookup keys from the provided array of tuples.
        """
        lookup_keys = []

        for keep in sender_receivers_tuple_list:
            receiver = keep[0]
            sender = keep[1]
            lookup_key = (_make_id(receiver) if receiver else receiver, _make_id(sender))
            lookup_keys.append(lookup_key)
        return lookup_keys

    def _remove_disallowed_receivers(self, receivers, lookup_keys):
        """
        Searches the receivers, discarding any that have a lookup_key in the lookup_keys list
        """
        kept_receivers = []
        for receiver in receivers:
            if not self._receiver_in_lookup_keys(receiver, lookup_keys):
                kept_receivers.append(receiver)
        return kept_receivers

    def setUp(self, sender_receivers_to_keep=None, sender_receivers_to_discard=None):
        """
        Turns off signals from other apps

        The `sender_receivers_to_keep` can be set to an array of tuples (reciever, sender,), preserving matching signals.
        The `sender_receivers_to_discard` can be set to an array of tuples (reciever, sender,), discarding matching signals.
            with both, you can set the `receiver` to None if you want to target all signals for a model
        """
        super(NoSignalTestCase, self).setUp()
        self.m2m_changed_receivers = m2m_changed.receivers
        self.pre_delete_receivers = pre_delete.receivers
        self.pre_save_receivers = pre_save.receivers
        self.post_delete_receivers = post_delete.receivers
        self.post_save_receivers = post_save.receivers

        new_m2m_changed_receivers = []
        new_pre_delete_receivers = []
        new_pre_save_receivers = []
        new_post_delete_receivers = []
        new_post_save_receivers = []

        if sender_receivers_to_keep:
            lookup_keys = self._create_lookup_keys(sender_receivers_to_keep)
            new_m2m_changed_receivers = self._find_allowed_receivers(self.m2m_changed_receivers, lookup_keys)
            new_pre_delete_receivers = self._find_allowed_receivers(self.pre_delete_receivers, lookup_keys)
            new_pre_save_receivers = self._find_allowed_receivers(self.pre_save_receivers, lookup_keys)
            new_post_delete_receivers = self._find_allowed_receivers(self.post_delete_receivers, lookup_keys)
            new_post_save_receivers = self._find_allowed_receivers(self.post_save_receivers, lookup_keys)

        if sender_receivers_to_discard:
            lookup_keys = self._create_lookup_keys(sender_receivers_to_discard)

            new_m2m_changed_receivers = self._remove_disallowed_receivers(new_m2m_changed_receivers or self.m2m_changed_receivers, lookup_keys)
            new_pre_delete_receivers = self._remove_disallowed_receivers(new_pre_delete_receivers or self.pre_delete_receivers, lookup_keys)
            new_pre_save_receivers = self._remove_disallowed_receivers(new_pre_save_receivers or self.pre_save_receivers, lookup_keys)
            new_post_delete_receivers = self._remove_disallowed_receivers(new_post_delete_receivers or self.post_delete_receivers, lookup_keys)
            new_post_save_receivers = self._remove_disallowed_receivers(new_post_save_receivers or self.post_save_receivers, lookup_keys)

        m2m_changed.receivers = new_m2m_changed_receivers
        pre_delete.receivers = new_pre_delete_receivers
        pre_save.receivers = new_pre_save_receivers
        post_delete.receivers = new_post_delete_receivers
        post_save.receivers = new_post_save_receivers

    def tearDown(self):
        """
        Restores the signals that were turned off.
        """
        super(NoSignalTestCase, self).tearDown()
        m2m_changed.receivers = self.m2m_changed_receivers
        pre_delete.receivers = self.pre_delete_receivers
        pre_save.receivers = self.pre_save_receivers
        post_delete.receivers = self.post_delete_receivers
        post_save.receivers = self.post_save_receivers


By default this will turn off all signals, but you can use the special setUp function to specify signals to keep or signals to discard. For example, if I wanted to turn off all signals to only the Comment model:

class CommentTestCase(NoSignalTestCase):
    def setUp(self):
        super(MentionMentionTestCase, self).setUp(sender_receivers_to_discard=[(None, Comment),])


Or only keep signals for the Comment model:

class CommentTestCase(NoSignalTestCase):
    def setUp(self):
        super(MentionMentionTestCase, self).setUp(sender_receivers_to_keep=[(None, Comment),])


And lastly, if you want to target a specific signal callback function (known as the receiver), pass that in instead of None, as the first value of the tuple:

def comment_pre_save_listener(sender, instance, **kwargs):
	# do something
    pass
pre_save.connect(comment_pre_save_listener, sender=Comment)
class CommentTestCase(NoSignalTestCase):
    def setUp(self):
        super(MentionMentionTestCase, self).setUp(sender_receivers_to_discard=[(comment_pre_save_listener, Comment),])

4 Comments
Tags: test, django, Python

Popular Posts


  • CSS String Truncation with Ellipsis
  • Super Simple Image Viewer
  • Numbers And Number Format Function
  • Building Better Forms
  • Extending Native String

Sites That Matter


  • A List Apart
  • Dojo
  • Internet Explorer Blog
  • JavaScript Magazine
  • John Resig
  • jQuery.com
  • Nicholas C. Zakas Online
  • Node.JS
  • Prototype JS
  • QuirksMode
  • YUI Library
spacer spacer
 
gipoco.com is neither affiliated with the authors of this page nor responsible for its contents. This is a safe-cache copy of the original web site.