skip to content

A Django ticket shop issues tickets in transaction.on_commit; why does its TestCase test never see the tickets, and when do you switch to TransactionTestCase?

level: seniorimportance: should knowfreq 44%

answer

  1. nothing ever commits
  2. callbacks dropped on rollback
  3. TransactionTestCase really commits
  4. flush cost after each test
  5. select_for_update needs a real transaction too

basics

~20 s

Django's TestCase runs every test inside transactions that are rolled back, so nothing commits and on_commit callbacks are discarded. TransactionTestCase lets the code commit, so the callbacks run, at the price of flushing tables after each test.

solid answer

~40 s

`transaction.on_commit()` queues a callback to run after the current transaction commits and drops it if the transaction rolls back. A `TestCase` test runs inside a class-level and a per-test `atomic` block, and the view's own `atomic` becomes a savepoint inside them. When the view finishes, nothing has committed, so `issue_tickets` never runs, and at teardown the rollback throws the queued callback away. Switch to `TransactionTestCase` when the test is about commit behaviour itself: callbacks firing, a rollback undoing work, `select_for_update()` needing a real transaction, or another connection seeing committed rows. Its cost is a flush of every table after each test and losing data from migrations. For simply asserting that a callback was registered, a capture helper inside `TestCase` is lighter; keep `TransactionTestCase` for the few end-to-end commit tests.

code

python · 26 lines
python
from functools import partial

from django.db import transaction
from django.test import TransactionTestCase

from tickets.models import Event, Order, Ticket
from tickets.services import issue_tickets


def place_order(event, seat_numbers):
    with transaction.atomic():
        order = Order.objects.create(event=event, seat_count=len(seat_numbers))
        event.seats.filter(number__in=seat_numbers).update(sold=True)
        transaction.on_commit(partial(issue_tickets, order.pk))
    return order


class PlaceOrderCommitTests(TransactionTestCase):
    def test_tickets_issued_after_commit(self):
        event = Event.objects.create(name="Jazz night", capacity=2)
        event.seats.create(number=1)
        event.seats.create(number=2)
        order = place_order(event, [1, 2])
        # The atomic block was the outermost transaction, so it committed
        # and issue_tickets ran before place_order returned.
        self.assertEqual(Ticket.objects.filter(order=order).count(), 2)

go deeper

for a junior

Know that TestCase never commits, so code that waits for a commit does not run its follow-up in those tests.

for a middle

Explain savepoints inside the test's atomic blocks, why callbacks are discarded, and what TransactionTestCase changes.

for a senior

Choose per test between capturing callbacks and a real commit, and catch transaction bugs such as select_for_update outside atomic that TestCase hides.

for a principal

Shape the suite so commit-path tests are few, deliberate and end to end, while the bulk stays in fast rollback-isolated tests.

## The code under test A checkout view reserves seats and creates an order in one transaction, then asks for the tickets to be issued only if that transaction commits: - `with transaction.atomic():` creates the order and marks the seats sold; - `transaction.on_commit(partial(issue_tickets, order.pk))` registers the follow-up. `on_commit` is designed for exactly this: if the order is rolled back, no tickets are issued for seats that were never sold. Outside any transaction, the callback runs immediately. ## Why `TestCase` never runs it `django.test.TestCase` isolates tests with **two nested `atomic` blocks** that are **rolled back**, never committed: 1. the class-level block opened in `setUpClass()`; 2. the per-test block opened before each test. When the view's `atomic` runs inside them, it is only a **savepoint**. Leaving it releases the savepoint, but the connection is still inside the test's transaction, so Django keeps the callback in its queue. At the end of the test, the per-test block is rolled back and the queued callbacks registered inside it are discarded. The test that asserts `Ticket.objects.filter(order=order).count() == 2` therefore fails, and the view looks broken when it is not. The same isolation hides other commit-dependent behaviour: | Behaviour | Inside `TestCase` | Inside `TransactionTestCase` | |---|---|---| | `on_commit` callbacks | queued, then discarded | run when the outermost `atomic` commits | | `select_for_update()` with no `atomic` around it | no error, because a transaction always exists | raises `TransactionManagementError` as in production | | Another connection or thread reading the rows | cannot see uncommitted data | sees committed data | | A rollback path you want to observe | mixed up with the test's own rollback | observable on its own | ## Two ways to test it - **Stay in `TestCase` and capture the callbacks.** Django's `TestCase` offers a context manager that collects the callbacks registered in a block and can run them as if a commit happened. That is fast and is the usual choice for asserting the side effect; it is covered with the other test-isolation helpers. - **Use `TransactionTestCase` for the real thing.** The view's `atomic` is then the outermost transaction; it commits, the callback runs, and the tickets exist when the assertion runs. ## What `TransactionTestCase` costs - After **each** test, Django runs `flush` on every database in `databases`, truncating all tables. With a large schema this is much slower than a rollback. - Rows inserted by **data migrations** (seat categories, default venues) disappear after the first flush; set `serialized_rollback = True` if later tests need them back. - There is no `setUpTestData()`; build data in `setUp()` or in the test. - The runner runs it after all `TestCase` classes, so a slow transactional class extends the tail of the suite. ## A sensible split 1. Unit-test `issue_tickets()` directly in a `TestCase`. 2. Test the view in a `TestCase` with the callbacks captured and executed, asserting the tickets. 3. Keep one `TransactionTestCase` that checks the real commit path end to end, including that a failed payment rolls back and issues nothing. That keeps the suite fast while still proving that the production wiring commits and fires. ## Durable blocks are the exception One transaction feature does work inside `TestCase`: `transaction.atomic(durable=True)`. A durable block normally refuses to nest inside another `atomic` block, which would make it unusable under `TestCase`'s two wrappers. Django marks the atomic blocks that `TestCase` opens, and the durability check ignores them, so code using durable blocks can still be tested in `TestCase`. The commit, though, is still not real: `on_commit` callbacks registered inside it are still queued until the test's rollback discards them.

  • Why does select_for_update() without an atomic block pass in a Django TestCase but fail in production?
    `select_for_update()` must run inside a transaction and raises `TransactionManagementError` otherwise. In a `TestCase`, every test already runs inside the test's own `atomic` blocks, so the missing transaction in the code is hidden. Only a `TransactionTestCase` runs the code in autocommit mode as production does, so it is the class that can catch the bug.
  • What does a Django TransactionTestCase remove that a TestCase preserves between tests?
    Data inserted by migrations. `TransactionTestCase` flushes every table after each test, so rows from data migrations are gone for later transactional tests. `TestCase` only rolls back its own changes. Set `serialized_rollback = True` on the transactional class to reload the post-migration state before each of its tests.

saying these in an interview costs you the question

  • on_commit callbacks run when a TestCase test finishes
  • The view's atomic block commits even inside TestCase
  • TransactionTestCase is the right default for all view tests
  • TransactionTestCase leaves migration data untouched
  • A select_for_update bug will show up in a TestCase test