October 2, 2013
by Mark Lavin
0 comments
Categories:
Technical

Skipping Test DB Creation

We are always looking for ways to make our tests run faster. That means writing tests which don't preform I/O (DB reads/writes, disk reads/writes) when possible. Django has a collection of TestCase subclasses for different use cases. The common TestCase handles the fixture loading and the creation the of TestClient. It uses the database transactions to ensure that the database state is reset for every test. That is it wraps each test in a transaction and rolls it back once the test is over. Any transaction management inside the test becomes a no-op. Since TestCase` overrides the transaction facilities, if you need to test the transactional behavior of a piece of code you can instead use TransactionTestCase. TransactionTestCase resets the database after the test runs by truncating all tables which is much slower than rolling back the transaction particularly if you have a large number of tables.

There is also SimpleTestCase which is the base class for the previous to classes. It has some additional assertions for testing HTML and overriding Django settings but doesn't manage the database state. If you are testing something that doesn't need to interact with the database such as form field/widget output, utility code or have mocked all of the database interactions you can save the overhead of the transaction by using SimpleTestCase.

Now what if you are running a set of tests which are only using SimpleTestCase or the base unittest.TestCase? Then you don't really need the test database creation at all. Depending on the backend you are using, the number of tables you have and the number of tests you are running the database creation can take many times longer than running the test themselves.

Our solution for this was to extend the default test runner. A quick examination of the build-in test runner reveals a solution.

def run_tests(self, test_labels, extra_tests=None, **kwargs):
    """
    Run the unit tests for all the test labels in the provided list.

    Test labels should be dotted Python paths to test modules, test
    classes, or test methods.

    A list of 'extra' tests may also be provided; these tests
    will be added to the test suite.

    Returns the number of tests that failed.
    """
    self.setup_test_environment()
    suite = self.build_suite(test_labels, extra_tests)
    old_config = self.setup_databases()
    result = self.run_suite(suite)
    self.teardown_databases(old_config)
    self.teardown_test_environment()
    return self.suite_result(suite, result)

The test suite is discovered before the test db is created. That means we can look at the set of tests which are going to be run and if none of them are using TransactionTestCase (TestCase is a subclass of TransactionTestCase) then we can skip the database creation/teardown. Here's what that looks like:

from django.test import TransactionTestCase
try:
    from django.test.runner import DiscoverRunner as BaseRunner
except ImportError:
    # Django < 1.6 fallback
    from django.test.simple import DjangoTestSuiteRunner as BaseRunner

from mock import patch


class NoDatabaseMixin(object):
    """
    Test runner mixin which skips the DB setup/teardown
    when there are no subclasses of TransactionTestCase to improve the speed
    of running the tests.
    """

    def build_suite(self, *args, **kwargs):
        """
        Check if any of the tests to run subclasses TransactionTestCase.
        """
        suite = super(NoDatabaseMixin, self).build_suite(*args, **kwargs)
        self._needs_db = any([isinstance(test, TransactionTestCase) for test in suite])
        return suite

    def setup_databases(self, *args, **kwargs):
        """
        Skip test creation if not needed. Ensure that touching the DB raises and
        error.
        """
        if self._needs_db:
            return super(NoDatabaseMixin, self).setup_databases(*args, **kwargs)
        if self.verbosity >= 1:
            print 'No DB tests detected. Skipping Test DB creation...'
        self._db_patch = patch('django.db.backends.util.CursorWrapper')
        self._db_mock = self._db_patch.start()
        self._db_mock.side_effect = RuntimeError('No testing the database!')
        return None

    def teardown_databases(self, *args, **kwargs):
        """
        Remove cursor patch.
        """
        if self._needs_db:
            return super(NoDatabaseMixin, self).teardown_databases(*args, **kwargs)
        self._db_patch.stop()
        return None


class FastTestRunner(NoDatabaseMixin, BaseRunner):
    """Actual test runner sub-class to make use of the mixin."""

There are a couple of things to note. Like the previous temporary MEDIA_ROOT runner, this is written as a mixin so that it can be combined with other test runner improvements. Second it uses mock to ensure that any attempts to connect to the database will fail. This idea is borrowed from Carl Meyer's Testing and Django Talk from PyCon 2012. To make use of this you would need to include this runner on your Python path and change the TEST_RUNNER setting to the full Python path to the FastTestRunner class.

With this in place if you had the follow tests

# myapp.tests.py
from django.test import TestCase, SimpleTestCase


class DbTestCase(TestCase):
    """Does something with the DB."""


class NoDbTestCase(SimpleTestCase):
    """Does something with the DB."""

in your app called myapp. If you were to run:

python manage.py test myapp

it would create the DB. However, if you run:

# For Django < 1.6
python manage.py test myapp.NoDbTestCase
# For Django 1.6+ with the DiscoverRunner
python manage.py test myapp.tests.NoDbTestCase

it would skip the test DB creation. Hooray for faster tests!

blog comments powered by Disqus