Transaction tests in Pytest

If you are coming from Django, you may have come across the slightly unfortunately named TransactionTestCase.

While the most commonly used TestCase cleans up after each test by rolling back the database transaction, TransactionTestCase takes the nuclear approach and instead cleans up by truncating all of the tables in the database.

There are some reasons why we would choose to use TransactionTestCase, for example if we are testing schema changing operations in a database that doesn't support rolling them back (*cough* MySQL *cough*), or if we want to test transaction-specific behaviour.

Oops, didn't mean to...

...destroy all your fixtures! 💣 💥

A common pattern in larger projects, in order to improve performance, is to create an initial database state based on some known data fixtures, and then use that as the starting point for all subsequent tests.

If we are using Django's TestCase then every time a test is done all changes to that state are reverted, and we're back to where we were 👍

If we then use TransactionTestCase however, after a test is done running, it'll completely destroy that state and, other than being usually slower than a rollback, it can become challenging to restore our data setup for any subsequent tests.

To work around this problem, any tests based on TransactionTestCase are executed after TestCase based tests have all finished, and it is just assumed that they can independently manage any setup/teardown that may be required.

What about Pytest?

pytest-django offers very similar features to the equivalent Django unittest based test cases.

By default, pytest-django will block database access, so we have to explicitly enable it by using the django_db mark:

@pytest.mark.django_db
def test_that_rollsback():
    User.object.filter(active=True).update(active=False)
    # ...

This will have the same behaviour as TestCase, meaning it will wrap the test in an atomic transaction that will eventually be rolled back.

To get the equivalent of TransactionTestCase, we have to slightly parametrise the mark with transaction=True:

@pytest.mark.django_db(transaction=True)
def test_that_truncates():
    # ... create tables, test transaction commit hooks...
    # 🔥🔥🔥

There is only one problem 🤔

If the test_that_truncates happens to run before test_that_rollsback, then any persistent fixtures that we may have pre-populated our database with will need to be recreated.

This could easily be the case if we create our state using session, module or class pytest fixtures.

At present there is no guarantee on the order the tests will run, which is generally a good thing, but in this case it may cause us a bit of trouble.

So how do we emulate Django's behaviour to run the "nuclear" tests last?

Pytest hooks to the rescue

Luckily, pytest provides a number of hooks for us so we can customise its behaviour in many different ways. One such hook is pytest_collection_modifyitems, which lets us have a peak at the tests that have been collected, before they run, and modify them as we please.

We can use this to re-order tests, base on any attribute, in our cased based on whether they are marked as a transaction test:

def _has_transactional_marker(item):
    db_marker = item.get_closest_marker("django_db")
    if db_marker and db_marker.kwargs.get("transaction"):
        return 1
    return 0


def pytest_collection_modifyitems(items):
    items.sort(key=_has_transactional_marker)

That's it ✔️ – this will guarantee that test_that_truncates will always run after test_that_rollsback, same as with the default Django unittest behaviour, and our precious data fixtures are now safe ☔

Update
There's an open issue regarding the order of tests on the pytest-django repo which may change things in the future. Also contains a similar solution if you haven't migrated your tests from Django test cases yet.