Filter concepts by levelShowing all levels.

Django · Database Testing

Database Testing

The rollback boundary, the constraints that outlive your serializers, and query counts as a test

  • TestCase rolls back, TransactionTestCase truncatesstay on the fast one until the thing under test IS the commit
  • captureOnCommitCallbacks(execute=True)test on_commit side effects without paying for a truncate per test
  • Wrap the expected IntegrityError in atomic()otherwise the abort poisons the test transaction and your real assertion never runs
  • Choose the query budget, do not record itthen re-run at 10x rows — a fixed count as data grows is the N+1 check
  • Data migrations get zero coverage by defaultthe test database is always migrated from empty, so RunPython sees no rows

1.The test database lifecycle, and transaction behaviour per test class

Your tests never touch the real database. Django creates a second one named `test_` plus your real database name, runs every migration into it, runs the suite, and destroys it at the end — pass or fail. Inside that database, the class you inherit from decides how each test is cleaned up. `TestCase` wraps each test in a transaction and rolls it back, which is fast. `TransactionTestCase` lets the test really commit and then truncates every table, which is slow but is the only way to test anything that depends on a commit actually happening.

Key features

  • The test database is `test_` + your `NAME`, created before the suite and destroyed after it — "regardless of whether the tests pass or fail"
  • `TestCase` "encloses the test code in a database transaction that is rolled back at the end of the test"; `TransactionTestCase` "resets the database after the test runs by truncating all tables"
  • `--keepdb` preserves the test database between runs and applies any new migrations to it, turning a 40-second migrate into nothing
  • Django runs all `TestCase` subclasses first, then `SimpleTestCase`/`TransactionTestCase`, then plain `unittest.TestCase` — order is a grouping, not alphabetical
  • In pytest-django a test has **no** database access until it is marked `@pytest.mark.django_db` or requests the `db` fixture
  • `@pytest.mark.django_db(transaction=True)` is the `TransactionTestCase` equivalent; without it "transaction operations are noops during the test"

Which class (or marker) to reach for, and what it costs

Which class (or marker) to reach for, and what it costs
You are testingDjango classpytest-djangoCleanup
ordinary reads and writes`TestCase``@pytest.mark.django_db`rollback — fast
`on_commit()` side effects`TestCase` + `captureOnCommitCallbacks``django_capture_on_commit_callbacks`rollback — fast
`select_for_update()` blocking`TransactionTestCase``django_db(transaction=True)`truncate — slow
a real race between two connections`TransactionTestCase``django_db(transaction=True)`truncate — slow
no database at all`SimpleTestCase`no markernothing to clean
The same test, cleaned up two different ways

TestCase — rollback

  • BEGIN before the test, ROLLBACK after it
  • Nothing is ever committed to the test database
  • `on_commit()` callbacks never fire on their own
  • A second connection cannot see the rows this test made
  • Fast: no table truncation between tests

TransactionTestCase — truncate

  • The test really commits
  • Every table is truncated afterwards
  • `on_commit()` fires, locks really block
  • A second connection sees the committed rows
  • Slow: pay a truncate per test, so use it deliberately
  • TestCase — rollback
    • BEGIN before the test, ROLLBACK after it
    • Nothing is ever committed to the test database
    • `on_commit()` callbacks never fire on their own
    • A second connection cannot see the rows this test made
    • Fast: no table truncation between tests
  • TransactionTestCase — truncate
    • The test really commits
    • Every table is truncated afterwards
    • `on_commit()` fires, locks really block
    • A second connection sees the committed rows
    • Slow: pay a truncate per test, so use it deliberately

Command

bash
python manage.py test --keepdb          # reuse test_<name>, apply new migrations
python manage.py test --parallel        # one test database per process
pytest --reuse-db                       # pytest-django's equivalent of --keepdb

Django builds `test_<your database>`, migrates it, and destroys it after the run — use `--keepdb` (or `pytest --reuse-db`) so you stop paying for the migrate every time. Inside it, `TestCase` rolls back and `TransactionTestCase` truncates. Stay on the fast one until the thing under test *is* the commit: reach for `captureOnCommitCallbacks(execute=True)` for `on_commit` side effects, and only pay for `transaction=True` when a second connection has to see committed rows.

2.Testing constraints, unique violations, and real races

A constraint is a promise the database keeps even when your Python is wrong, so the test for it must go around your Python. Write the duplicate row directly and assert the database raises `IntegrityError` — a test that goes through your serializer only proves the serializer works. Two rules make these tests behave: wrap the failing write in `transaction.atomic()`, because an `IntegrityError` poisons the surrounding transaction and every later query in the test fails with a confusing error; and give a genuine two-connection race `transaction=True`, because a race between two connections needs both of them to see committed rows.

Key features

  • Test a constraint by writing the row directly (`Model.objects.create`), not through a form or serializer — that is the only version that survives a new write path
  • `IntegrityError` aborts the surrounding transaction: wrap the expected failure in `with transaction.atomic():` or every later query in the test raises `TransactionManagementError`
  • `assertRaises(IntegrityError)` alone is a weak assertion — also assert *which* constraint fired by matching its name in the message
  • Deferred constraints (`DEFERRABLE INITIALLY DEFERRED`) raise at COMMIT, so a rollback-based `TestCase` never sees them
  • A real race needs `transaction=True` plus threads; one connection cannot race itself
  • Close the thread's connection in a `finally` — a leaked connection makes the truncate at teardown hang

What each layer catches, and the test that proves it

What each layer catches, and the test that proves it
LayerFails withTest it by
`clean()` / serializer validation`ValidationError`, field-levelposting the duplicate through the API and asserting the field name
`UniqueConstraint``IntegrityError``Model.objects.create()` twice, inside `atomic()`
`CheckConstraint``IntegrityError`creating a row with the forbidden value directly
`ForeignKey` referential integrity`IntegrityError`deleting the parent and asserting `PROTECT` raises
a deferred constraint`IntegrityError` **at commit**`transaction=True`; a rollback test never commits
The race the constraint test cannot see, and the one it can
worker A
worker B
PostgreSQL
  1. 1SELECT … WHERE reference = "A-1"the check
  2. 20 rows — looks free
  3. 3SELECT … WHERE reference = "A-1"B checks in the same window
  4. 40 rows — also looks free
  5. 5INSERT reference = "A-1"
  6. 6COMMIT — row 1 exists
  7. 7INSERT reference = "A-1"the check is now stale
  8. 8IntegrityError — unique_active_referencethe constraint holds where the check did not
  1. worker A → PostgreSQL: SELECT … WHERE reference = "A-1" (the check)
  2. PostgreSQL → worker A: 0 rows — looks free
  3. worker B → PostgreSQL: SELECT … WHERE reference = "A-1" (B checks in the same window)
  4. PostgreSQL → worker B: 0 rows — also looks free
  5. worker A → PostgreSQL: INSERT reference = "A-1"
  6. PostgreSQL → worker A: COMMIT — row 1 exists
  7. worker B → PostgreSQL: INSERT reference = "A-1" (the check is now stale)
  8. PostgreSQL → worker B: IntegrityError — unique_active_reference (the constraint holds where the check did not)

Command

python
with pytest.raises(IntegrityError):
    with transaction.atomic():        # keeps the abort local to this block
        Order.objects.create(reference="A-1")

Test a constraint by writing the row directly, so the test still fails when a new write path skips your serializer — and assert the constraint *name*, not only the exception class. Always wrap the expected `IntegrityError` in its own `transaction.atomic()`, or the abort poisons the test transaction and your real assertion never runs. Save `transaction=True` and threads for invariants where two workers genuinely compete, use a `Barrier` rather than a `sleep`, and close each thread's connection in a `finally`.

3.Query-count assertions, factories, and migration tests

`assertNumQueries(7, func)` fails when the code runs a different number of queries than you said. That is how an N+1 regression becomes a red test instead of a slow page, because adding one field to a serializer can turn 7 queries into 700 without changing a single assertion about the response body. Factories (`factory_boy`) build the rows those tests need without a hand-written `create()` for every field, and `SubFactory` builds the related rows too. Migration tests are the third kind: they run a data migration against rows that existed *before* it and assert what it did to them.

Key features

  • `assertNumQueries(num, func, *args, **kwargs)` "asserts that when func is called … num database queries are executed"; it also works as a context manager
  • pytest-django ships `django_assert_num_queries` and `django_assert_max_num_queries`, both wrapping `CaptureQueriesContext`
  • Use the max variant for endpoints with legitimately variable counts, and the exact one everywhere else — an exact count is what catches a *reduction* that broke correctness too
  • Both take `using=` to count against a non-default database alias
  • A `SubFactory` builds related objects; `factory.Sequence` keeps unique fields unique without your test naming them
  • A data migration is never exercised by the normal suite — the test database is migrated from empty, so its `RunPython` always sees zero rows

The three assertions, and the failure each one catches

The three assertions, and the failure each one catches
AssertionCatchesUse when
`django_assert_num_queries(7)`an N+1 introduced by a serializer or template changethe count is genuinely fixed — most list endpoints
`django_assert_max_num_queries(12)`unbounded growth, while tolerating branch-dependent countsthe count varies with the caller's permissions or feature flags
`CaptureQueriesContext` + inspect `.captured_queries`the *wrong* query — a missing `WHERE tenant_id`the count is right but the SQL is not
Three database-test shapes, and what only each one can prove

Query-count tests

assertNumQueries(7)

exact — catches N+1 and over-fetching

max_num_queries(12)

for counts that vary by branch

captured_queries

inspect the SQL, not only the count

Factories

OrderFactory()

a valid row in one line

SubFactory

builds the related rows too

override one field

the field under test is the only one named

Migration + integration tests

migrate to N-1, insert, migrate

the only way a RunPython sees rows

real stack, no mocks

view + ORM + database together

few and slow by design

keep them out of the fast suite

  • Query-count tests — performance as a deterministic assertion
    • assertNumQueries(7) — exact — catches N+1 and over-fetching
    • max_num_queries(12) — for counts that vary by branch
    • captured_queries — inspect the SQL, not only the count
  • Factories — setup that reads as intent
    • OrderFactory() — a valid row in one line
    • SubFactory — builds the related rows too
    • override one field — the field under test is the only one named
  • Migration + integration tests — the two the normal suite cannot reach
    • migrate to N-1, insert, migrate — the only way a RunPython sees rows
    • real stack, no mocks — view + ORM + database together
    • few and slow by design — keep them out of the fast suite

Command

python
with django_assert_num_queries(3):
    client.get("/api/orders/")

self.assertNumQueries(3, lambda: client.get("/api/orders/"))   # unittest style

Choose the query budget rather than recording it, name what the queries are in a comment, and run the same request again at ten times the rows — a count that holds as the data grows is what proves there is no N+1. Use factories so each test names only the field it is about. And remember that data migrations get zero coverage from the normal suite, because the test database is always migrated from empty: the only real test moves the schema back one migration, inserts old-shaped rows, and migrates forward.

Remember

  • The test database is `test_` + your NAME, created before the suite and destroyed after it — `--keepdb` (or `pytest --reuse-db`) stops you paying for the migrate every run.
  • Test a constraint by writing the row directly, never through a serializer: that is the only version still failing when a CSV importer skips your validation.
  • Assert the constraint name, not only `IntegrityError` — otherwise any unique violation satisfies the test.
  • A race test needs `transaction=True`, threads, a `Barrier` rather than a `sleep`, and `connection.close()` in a `finally`.

Pro tip

  • Write the query count with a comment naming each query. A bare number is impossible to review when someone later changes it to a bigger one.
  • A leaked thread connection makes teardown's TRUNCATE hang rather than fail, which reads as a frozen CI job with no error at all.

Next up

  • Contract and Integration Testing
  • Performance Fundamentals
  • Django Performance Debugging

Sources

  • Django testing tools — docs.djangoproject.com/en/5.2/topics/testing/tools/
  • pytest-django helpers — pytest-django.readthedocs.io/en/latest/helpers.html
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.