unit_test_runner.py

Django unit testing : the right way

I’ve been working for years with Django and I really appreciate it. But one thing I never understood is why there is nothing “builtin” to run a suite of tests without any database handling. So I travelled the Internet to find a solution and so was it : I have to write my own Test runner … and that what I did.

Unit tests Vs Integration Tests

Understand me well, the provided Django test feature is very good and very useful but all run tests are de facto Integrations tests which is quite annoying. What’s the difference you would say ? Well, if you trust Atlassian :

  • Unit tests are very low level, close to the source of your application. They consist in testing individual methods and functions of the classes, components or modules used by your software. Unit tests are in general quite cheap to automate and can be run very quickly by a continuous integration server.
  • Integration tests verify that different modules or services used by your application work well together. For example, it can be testing the interaction with the database or making sure that microservices work together as expected. These types of tests are more expensive to run as they require multiple parts of the application to be up and running.

They have mainly two differences that interests us here :

  • Unit tests do not need any database
  • Unit tests are faster

For these two reasons, in development, we run unit tests far more often than integration ones. To be able to do that we need a way to determine if a given test is a integration one or a unit one.

How Django handle that

Django does, in fact, provide a way to distinct unit test from integration test. Indeed, it allows us to override two different classes depending on what we need.

We inherit from SimpleTestCase for a unit test :

from django.test import SimpleTestCase

class MyUnitTestCase(SimpleTestCase):
pass

SimpleTestCase don’t bother to use transactions and rollback any database modifications between tests. It is the most basic TestCase class provided by Django.

For an integration test, we inherit from TestCase:

from django.test import TestCase

class MyIntegrationTestCase(TestCase):
pass

Fine, but how do you run tests ? python manage.py test will work but all tests will be run including unit tests. We need a way to run only unit tests to get rid of database handling and gain a (potentially) huge amount of time (think about the time needed just to create the test database if you have 500 migration files). This way we need is to write a test runner.

Writing a unit test Django test runner

By default, Django use the DiscoverRunner, we are just going to inherit from it and change some of its behaviors.

Firstly, we are going to disable all database-linked stuff. For that, we override setup_databases and teardown_databases methods and make them do nothing.

from django.test.runner import DiscoverRunner

class
UnitTestRunner(DiscoverRunner):
def setup_databases(self, **kwargs):
pass
    def teardown_databases(self, old_config, **kwargs):
pass

Like that, it is already working… kind of. Indeed, all tests run by this runner will not trigger any databases creation. That’s great but as all tests will be run, all the integration ones will fail. At this point, we need to filter the list of tests to keep only unit tests. For that we need to override another method.

DiscoverRunner use build_suite method to create a TestSuite object that contains all the tests to run. Overriding this method allows us to perform our filtering. As TestSuite does not allow the removing of test, the solution is to create a new one with the filtered list.

from django.test import TransactionTestCase
from django.test.runner import DiscoverRunner
from unittest.suite import TestSuite

class
UnitTestRunner(DiscoverRunner):
def setup_databases(self, **kwargs):
pass
    def teardown_databases(self, old_config, **kwargs):
pass
    def build_suite(self, **kwargs):
suite = super().build_suite(**kwargs)
tests = [t for t in suite._tests if self.is_unittest(t)]
return TestSuite(tests=tests)
    def is_unittest(self, test):
return not issubclass(test.__class__, TransactionTestCase)

And that’s it, the runner is ready to be used !

Using the Runner

The runner is now defined, but how do we use it instead of the default one ? Well, it’s quite easy. The Django test command accepts --testrunner option in which you can provide the desired runner classname. Given our UnitTestRunner is defined in unit_test_runner.py we can use the following command.

python manage.py test --testrunner="unit_test_runner.UnitTestRunner"

It is also possible to change the default Django test runner by giving a value to the setting TEST_RUNNER . In that case, you’ll have to provide the default one to the test command when you want to run integration tests.

Going further

The usage shown above can be tricky as we need to write a rather long command and we have to remember the path and the name of the runner. To simplify this, we define a command to “replace” test . We inherit from base test command and we force the testrunner value to our test runner.

# unittest.py
from
django.core.management.commands.test import Command as TCommand


class Command(TCommand):

def execute(self, *args, **options):
test_class = 'oncokdm_api.runner.unittest.UnitTestRunner'
options['testrunner'] = test_class
super().execute(*args, **options)

We can then run unit tests by running : python manage.py unittest .

And that’s it, thanks for reading !

Resources