Testing Django Fields

Aug 2013

I love the flexibility that custom Django fields, abstract models, managers, and querysets offer, but unit testing them is a pain. Ideally, the tests for custom Django fields should be completely isolated from the models that use the fields in production; deciding that, for example, my User model no longer needs to support soft deletion shouldn’t affect the tests for the soft-deletion field itself.

The most common approach to this problem is simple, if annoying: declare all test-specific models in test/models.py, but don’t include the test app in INSTALLED APPS. In your test suite’s setup method, monkey-patch your settings to include the test app and run Django’s syncdb command, and un-patch your settings in the teardown method. Dynamically altering your settings in the test suite keeps your production database clean — a rogue syncdb won’t suddenly create dozens of useless new tables. My biggest gripe with this approach, though, is that it forces you to separate your test code into two files. The tests become much harder to read, and the file of test models inevitably becomes a crufty mess.

After a few months of low-level frustration, I finally came up with a better solution. By making all my test models inherit from this abstract model, I can have it all: no raw SQL, no test tables in production, and model definitions alongside my test code.

 1from django.core.management.color import no_style
 2from django.db import connection, models
 3
 4
 5class TestModel(models.Model):
 6
 7    class Meta:
 8        abstract = True
 9
10    @classmethod
11    def create_table(cls):
12        # Cribbed from Django's management commands.
13        raw_sql, refs = connection.creation.sql_create_model(
14            cls,
15            no_style(),
16            [])
17        create_sql = u'\n'.join(raw_sql).encode('utf-8')
18        cls.delete_table()
19        cursor = connection.cursor()
20        try:
21            cursor.execute(create_sql)
22        finally:
23            cursor.close()
24
25    @classmethod
26    def delete_table(cls):
27        cursor = connection.cursor()
28        try:
29            cursor.execute('DROP TABLE IF EXISTS %s' % cls._meta.db_table)
30        except:
31            # Catch anything backend-specific here.
32            # (E.g., MySQLdb raises a warning if the table didn't exist.)
33            pass
34        finally:
35            cursor.close()

To avoid boilerplate table management in my test setup and teardown code, I added a little functionality to Django’s built-in TestCase.

 1from django.test import TestCase
 2
 3
 4class ModelTestCase(TestCase):
 5    temporary_models = tuple()
 6
 7    def setUp(self):
 8        self._map_over_temporary_models('create_table')
 9        super(ModelTestCase, self).setUp()
10
11    def tearDown(self):
12        self._map_over_temporary_models('delete_table')
13        super(ModelTestCase, self).tearDown()
14
15    def _map_over_temporary_models(self, method_name):
16        for m in self.temporary_models:
17            try:
18                getattr(m, method_name)()
19            except AttributeError:
20                raise TypeError("%s doesn't support table mgmt." % m)

Looking for an example? Here’s a section of the test suite for my soft-deletion field:

 1from django.db import IntegrityError, models
 2
 3from myproject.soft_deletion.models import SoftDeletionModel
 4from myproject.test.models import TestModel
 5from myproject.test.testcase import ModelTestCase
 6
 7
 8class Person(SoftDeletionModel, TestModel):
 9    name = models.CharField(max_length=20)
10
11    class Meta:
12        unique_together = ('name', 'alive')
13
14
15class SoftDeletionTests(ModelTestCase):
16    temporary_models = (Person,)
17
18    def test_inits_alive(self):
19        p = Person.objects.create(name='Alive')
20        self.assertTrue(p.alive)
21
22    def test_allows_many_deleted_with_same_name(self):
23        Person.objects.create(name='Akshay').delete()
24        Person.objects.create(name='Akshay').delete()
25
26        # One un-deleted Akshay is okay.
27        Person.objects.create(name='Akshay')
28        self.assertEqual(Person.all_objects.count(), 3)
29
30        # Resurrecting one of the dupes violates constraint.
31        first = Person.all_objects.all()[0]
32        first.alive = True
33        self.assertRaises(IntegrityError, first.save)

Questions? Have a better idea? Let me know!