Skip to content

Testing after-commit callbacks

The Problem

Django's after-commit callbacks don't work properly in tests when using Django's atomic. This creates a disconnect between test behaviour and production behaviour, potentially hiding bugs.

Consider this function that should return A, B, C, D in order:

from functools import partial
from django.db import transaction

def build_ABCD():
    my_list = []
    with transaction.atomic():
        my_list.append("A")
        transaction.on_commit(partial(my_list.append, "C"))
        my_list.append("B")
    my_list.append("D")
    return my_list

In production

This returns ["A", "B", "C", "D"] as expected.

In tests

from django.test import TestCase

class TestBuildABCD(TestCase):
    def test_build_ABCD():
        built = build_ABCD()
        assert built == ["A", "B", "C", "D"]  # This will fail!

This fails because build_ABCD() returns ["A", "B", "D"], due to the fact that the after-commit callback never runs!

Why this happens

Django's TestCase runs each test in a transaction, which is rolled back at the end of the test to prevent test pollution. Because the test transaction never commits, Django does not run the after-commit callbacks.

The Solution: django_subatomic.db.transaction

Use Subatomic's transaction instead of Django's atomic in your application code

 from functools import partial
+from django_subatomic.db import transaction as subatomic_transaction
 from django.db import transaction as django_transaction

 def build_ABCD():
     my_list = []
-    with transaction.atomic():
+    with subatomic_transaction():
         my_list.append("A")
         django_transaction.on_commit(partial(my_list.append, "C"))
         my_list.append("B")
     my_list.append("D")
     return my_list

Subatomic's transaction explicitly represents a transaction, so tests can safely run after-commit callbacks when it exits. This provides realistic production behaviour without the downsides of other approaches.

Alternatives considered

Two alternative approaches were available to mitigate the above problem of Django's after-commit callbacks not working properly, but with some caveats.

⚠️ Using captureOnCommitCallbacks (timing issues)

from django.test import TestCase

class TestBuildABCD(TestCase):
    def test_build_ABCD(self):
        with self.captureOnCommitCallbacks(execute=True)
            built = build_ABCD() # This returns `["A", "B", "D", "C"]`
        assert built == ["A", "B", "C", "D"]  # This will fail!

captureOnCommitCallbacks captures and runs after-commit callbacks, but executes them after the tested function completes. While the callbacks do run, the execution order differs from production, potentially masking timing-dependent bugs.

⚠️ Using transaction test cases (potentially very slow)

from django.test import TransactionTestCase

class TestBuildABCD(TransactionTestCase)
    def test_build_ABCD():
        built = build_ABCD() # This returns `["A", "B", "C", "D"]`
        assert built == ["A", "B", "C", "D"]

While the callbacks do run and the order of the results are correct, this can be extremely slow in large scale applications, because Django must truncate all tables after each test instead of rolling back a transaction.