|
| 1 | +# Testing after-commit callbacks |
| 2 | + |
| 3 | +## The Problem |
| 4 | + |
| 5 | +Django's after-commit callbacks don't work properly in tests |
| 6 | +when using Django's [`atomic`][atomic]. |
| 7 | +This creates a disconnect between test behaviour and production behaviour, potentially hiding bugs. |
| 8 | + |
| 9 | +Consider this function that should return `A`, `B`, `C`, `D` in order: |
| 10 | + |
| 11 | +```python |
| 12 | +from functools import partial |
| 13 | +from django.db import transaction |
| 14 | + |
| 15 | +def build_ABCD(): |
| 16 | + my_list = [] |
| 17 | + with transaction.atomic(): |
| 18 | + my_list.append("A") |
| 19 | + transaction.on_commit(partial(my_list.append, "C")) |
| 20 | + my_list.append("B") |
| 21 | + my_list.append("D") |
| 22 | + return my_list |
| 23 | +``` |
| 24 | + |
| 25 | +### In production |
| 26 | + |
| 27 | +This returns `["A", "B", "C", "D"]` as expected. |
| 28 | + |
| 29 | +### In tests |
| 30 | + |
| 31 | +```python |
| 32 | +from django.test import TestCase |
| 33 | + |
| 34 | +class TestBuildABCD(TestCase): |
| 35 | + def test_build_ABCD(): |
| 36 | + built = build_ABCD() |
| 37 | + assert built == ["A", "B", "C", "D"] # This will fail! |
| 38 | +``` |
| 39 | + |
| 40 | +This fails because `build_ABCD()` returns `["A", "B", "D"]`, |
| 41 | +due to the fact that the after-commit callback never runs! |
| 42 | + |
| 43 | +## Why this happens |
| 44 | + |
| 45 | +Django's [`TestCase`][TestCase] runs each test in a transaction, |
| 46 | +which is rolled back at the end of the test to prevent test pollution. |
| 47 | +Because the test transaction never commits, Django does not run the after-commit callbacks. |
| 48 | + |
| 49 | +## The Solution: `django_subatomic.db.transaction` |
| 50 | + |
| 51 | +Use Subatomic's [`transaction`][django_subatomic.db.transaction] instead of Django's [`atomic`][atomic] in your application code |
| 52 | + |
| 53 | +```diff |
| 54 | + from functools import partial |
| 55 | ++from django_subatomic.db import transaction as subatomic_transaction |
| 56 | + from django.db import transaction as django_transaction |
| 57 | + |
| 58 | + def build_ABCD(): |
| 59 | + my_list = [] |
| 60 | +- with transaction.atomic(): |
| 61 | ++ with subatomic_transaction(): |
| 62 | + my_list.append("A") |
| 63 | + django_transaction.on_commit(partial(my_list.append, "C")) |
| 64 | + my_list.append("B") |
| 65 | + my_list.append("D") |
| 66 | + return my_list |
| 67 | + |
| 68 | +``` |
| 69 | + |
| 70 | +Subatomic's [`transaction`][django_subatomic.db.transaction] explicitly represents a transaction, |
| 71 | +so tests can safely run after-commit callbacks when it exits. |
| 72 | +This provides realistic production behaviour without the downsides of other approaches. |
| 73 | + |
| 74 | +## Alternatives considered |
| 75 | + |
| 76 | +Two alternative approaches were available |
| 77 | +to mitigate the above problem of Django's after-commit callbacks not working properly, |
| 78 | +but with some caveats. |
| 79 | + |
| 80 | +### ⚠️ Using `captureOnCommitCallbacks` (timing issues) |
| 81 | + |
| 82 | +```python |
| 83 | + |
| 84 | +from django.test import TestCase |
| 85 | + |
| 86 | +class TestBuildABCD(TestCase): |
| 87 | + def test_build_ABCD(self): |
| 88 | + with self.captureOnCommitCallbacks(execute=True) |
| 89 | + built = build_ABCD() # This returns `["A", "B", "D", "C"]` |
| 90 | + assert built == ["A", "B", "C", "D"] # This will fail! |
| 91 | +``` |
| 92 | + |
| 93 | +[`captureOnCommitCallbacks`][captureOnCommitCallbacks] |
| 94 | +captures and runs after-commit callbacks, but executes them after the tested function completes. |
| 95 | +While the callbacks do run, the execution order differs from production, potentially masking timing-dependent bugs. |
| 96 | + |
| 97 | +### ⚠️ Using transaction test cases (potentially very slow) |
| 98 | + |
| 99 | +```python |
| 100 | +from django.test import TransactionTestCase |
| 101 | + |
| 102 | +class TestBuildABCD(TransactionTestCase) |
| 103 | + def test_build_ABCD(): |
| 104 | + built = build_ABCD() # This returns `["A", "B", "C", "D"]` |
| 105 | + assert built == ["A", "B", "C", "D"] |
| 106 | +``` |
| 107 | + |
| 108 | +While the callbacks do run and the order of the results are correct, |
| 109 | +this can be extremely slow in large scale applications, |
| 110 | +because Django must truncate all tables after each test instead of rolling back a transaction. |
| 111 | + |
| 112 | +[atomic]: https://docs.djangoproject.com/en/stable/topics/db/transactions/#django.db.transaction.atomic |
| 113 | +[captureOnCommitCallbacks]: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.TestCase.captureOnCommitCallbacks |
| 114 | +[TestCase]: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.TestCase |
0 commit comments