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!