How to convert a TestCase from setUp() to setUpTestData()

2021-04-12 “Owl help you speed up your tests!”

Django’s TestCase class provides the setUpTestData() hook for creating your test data. It is faster than using the unittest setUp() hook because it creates the test data only once per test case, rather than per test.

Converting TestCases from setUp() to setUpTestData() is one of the most reliable ways to speed up a test suite, with no costs beyond from the time it might take to change your code. I’ve often found it confers a 3x speedup, or more.

Here’s my how-to guide for doing this conversion for a single TestCase. Rinse and repeat across your code base!

Example Test Case

We’ll convert this test case:

from django.contrib.auth.models import User
from django.test import TestCase

from example.core.models import Book


class IndexTests(TestCase):
    def setUp(self):
        self.book = Book.objects.create(title="The Checklist Manifesto")
        self.user = User.objects.create_user(
            username="tester",
            email="test@example.com",
        )
        self.client.force_login(self.user)

    def test_one(self):
        ...

    def test_two(self):
        ...

0. Install django-testdata on Django < 3.2

Before Django 3.2, use of setUpTestData() has a major caveat: Django rolls back changes to model instances in the database but not in memory. This means you often need extra code to re-fetch data to ensure tests are correctly isolated.

Django 3.2 includes a fix: TestCase now copies any objects created in setUpTestData() on-demand for subsequent tests. Your tests can thus change objects as needed, with following tests seeing the original versions.

Luckily, this behaviour is available for older Django versions in the django-testdata package. Install this if you’re using Django < 3.2 - you can remove it when you upgrade in the future.

1. Run the target test case

This will check the tests currently pass and gives us a baseline for how long they take. Run the test command to run just this test case, with the database reuse flag to reduce startup time.

For example with Django’s test framework:

$ ./manage.py test --keepdb example.core.tests.IndexTests
Using existing test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.015s

OK
Preserving test database for alias 'default'...

Or with pytest:

$ pytest --reuse-db example/core/tests.py::IndexTests

2. Add a stub setUpTestData()

Add a setUpTestData() class method in the test case class.

On Django 3.2+ this can be a vanilla class method:

 class IndexTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+
     def setUp(self):
         self.book = Book.objects.create(title="The Checklist Manifesto")
         self.user = User.objects.create_user(

On Django < 3.2 you’ll also need to import and use @wrap_testdata from django-testdata:

 from django.contrib.auth.models import User
 from django.test import TestCase
+from testdata import wrap_testdata

 from example.core.models import Book


 class IndexTests(TestCase):
+    @classmethod
+    @wrap_testdata
+    def setUpTestData(cls):
+
     def setUp(self):
         self.book = Book.objects.create(title="The Checklist Manifesto")
         self.user = User.objects.create_user(

3. Move data creation from setUp() to setUpTestData()

Move all code that creates data from setUp() to setUpTestData(), and change its use of self to cls. This can mean moving code that creates model instances, supports such creation, or that creates other expensive objects. Other per-test setup, such as using methods on the test client, cannot normally live in setUpTestData() as it won’t be reset between tests.

If nothing remains in setUp() after this, delete it.

In our example this change looks like:

 class IndexTests(TestCase):
     @classmethod
     def setUpTestData(cls):
+        cls.book = Book.objects.create(title="The Checklist Manifesto")
+        cls.user = User.objects.create_user(
+            username="tester",
+            email="test@example.com",
+        )

     def setUp(self):
-        self.book = Book.objects.create(title="The Checklist Manifesto")
-        self.user = User.objects.create_user(
-            username="tester", email="test@example.com"
-        )
         self.client.force_login(self.user)

     def test_one(self):

With this final result:

from django.contrib.auth.models import User
from django.test import TestCase

from example.core.models import Book


class IndexTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.book = Book.objects.create(title="The Checklist Manifesto")
        cls.user = User.objects.create_user(
            username="tester",
            email="test@example.com",
        )

    def setUp(self):
        self.client.force_login(self.user)

    def test_one(self):
        ...

    def test_two(self):
        ...

4. Re-run tests

Re-run the test command from step one to check the conversion has been successful, and to see any time savings:

$ ./manage.py test --keepdb example.core.tests.IndexTests
Using existing test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.012s

OK
Preserving test database for alias 'default'...

We don’t see much difference in this example because there isn’t much test data, and we only have two tests, but it is a few milliseconds faster. In real world test cases you should see more change.

Done

That’s all it takes! Repeat for the next test case in your suite.

If you’re trying to convert your whole test suite, try working in batches, starting with the slowest cases.

Fin

Thanks to Simon Charette for creating django-testdata and submitting it for merging into Django 3.2.

May your tests run ever faster,

—Adam


Want better tests? Check out my book Speed Up Your Django Tests which teaches you to write faster, more accurate tests.


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: django, python