Filter concepts by levelShowing all levels.

Django · Section 66

Django Testing

Level
intermediate
Read
36 min
Concepts
4

Django ships three test-case classes that differ only in how they treat the database, and the difference decides whether a test can observe what it claims to. `SimpleTestCase` forbids database access entirely, which turns "this should be pure" into an enforced rule. `TestCase` wraps each test in a transaction and rolls it back — fast, isolated, and the right default — but because it never commits, `transaction.on_commit()` callbacks never fire and a second connection cannot see the writes, so a test asserting on those silently passes without exercising anything; `captureOnCommitCallbacks(execute=True)` is the fix, not switching classes. `TransactionTestCase` really commits and truncates between tests, and is only worth its substantial cost when the commit itself is the subject. Above that sits the question of *which layer to test*: the test client runs the full middleware and URL stack in-process and is the only way to observe redirects, permissions and template selection, while a model method or a form is a direct call whose failure has one suspect rather than five. Two habits keep client tests durable — `reverse()` instead of literal paths, and assertions on `response.context` rather than on rendered HTML. The pytest side inverts one Django default deliberately: database access is blocked unless a test asks with `@pytest.mark.django_db` or the `db` fixture, which makes reaching the database visible in the source. Fixtures are dependency injection with a `scope`, and the trap is that `db` is function-scoped, so no session-scoped fixture may request it — session scope is for expensive read-only work, and seeding a whole run goes through `django_db_setup` plus `django_db_blocker.unblock()`. Finally, parameterization turns near-identical tests into one body and a table that documents the boundaries considered, marks let a slow suite be split, async tests need `pytest-asyncio` and still hit `SynchronousOnlyOperation` on the plain ORM, and factories move every irrelevant field behind a realistic default.

What is true here

  1. TestCase never commits, so on_commit callbacks do not fire — use captureOnCommitCallbacks, not a slower class.
  2. Test at the narrowest layer that can fail: the client for assembled behaviour, direct calls for models and forms.
  3. Use reverse() and response.context, never literal paths or HTML assertions.
  4. pytest-django blocks the database by default; db is function-scoped and cannot be requested by a session fixture.
  5. Factories and parametrize both remove code that says more than the test is about.

What you will be able to do

  • Choose the cheapest test-case class that can actually observe the behaviour under test
  • Decide which layer a given behaviour belongs to, and write a test with one reason to fail
  • Use pytest-django fixtures and scopes correctly, including around the database
  • Replace repetitive setup with factories and parameterized tables without hiding what matters
Choosing how to test a given behaviour
on_commitonlylocking, secondconnection

What is the behaviour?

Pure logic — a filter, a calculation

A model method or a form rule

Only exists assembled — redirect, permission, template

Depends on a real COMMIT

on_commit, select_for_update, a second connection

SimpleTestCase

database forbidden — the ban is the assertion

TestCase / @pytest.mark.django_db

rolled-back transaction; fast; the default

TestCase + self.client

reverse() for the URL, response.context for the assertion

captureOnCommitCallbacks(execute=True)

test the callback without leaving the fast class

TransactionTestCase / django_db(transaction=True)

real commits, truncation, slow — only when the commit IS the subject

  • What is the behaviour?
    • leads to Pure logic — a filter, a calculation
    • leads to A model method or a form rule
    • leads to Only exists assembled — redirect, permission, template
    • leads to Depends on a real COMMIT
  • Pure logic — a filter, a calculation
    • leads to SimpleTestCase
  • A model method or a form rule
    • leads to TestCase / @pytest.mark.django_db
  • Only exists assembled — redirect, permission, template
    • leads to TestCase + self.client
  • Depends on a real COMMIT — on_commit, select_for_update, a second connection
    • leads to captureOnCommitCallbacks(execute=True) (on_commit only)
    • on error, leads to TransactionTestCase / django_db(transaction=True) (locking, second connection)
  • SimpleTestCase — database forbidden — the ban is the assertion
  • TestCase / @pytest.mark.django_db — rolled-back transaction; fast; the default
  • TestCase + self.client — reverse() for the URL, response.context for the assertion
  • captureOnCommitCallbacks(execute=True) — test the callback without leaving the fast class
    • leads to TestCase / @pytest.mark.django_db
  • TransactionTestCase / django_db(transaction=True) — real commits, truncation, slow — only when the commit IS the subject

Built-in testing

The three test-case classes, and the test client that runs the whole stack in-process.

SimpleTestCase, TestCase, and TransactionTestCase

coreintermediate

The three classes differ in how they handle the database, and choosing wrongly is either slow or subtly broken. `SimpleTestCase` **forbids** database access — touching it raises, which is exactly what you want for a template filter or a pure function. `TestCase` wraps each test in a transaction and rolls it back afterwards, which makes it fast and is the right default. `TransactionTestCase` genuinely commits and then truncates the tables between tests, which is much slower but is the only one that can test anything involving real commits — `transaction.on_commit()` callbacks, `select_for_update()`, or code that expects another connection to see its writes.

Think of it as

`TestCase`'s speed comes from never committing, and that single fact explains every case where it gives the wrong answer. Your code under test runs inside a transaction that will be rolled back, so anything whose behaviour depends on a commit does not happen: an `on_commit` callback never fires, a second database connection cannot see your writes, and `select_for_update()` has no competing transaction to block against. None of that produces an error — the test simply passes without exercising the thing you meant to test, which is worse than failing. So the rule is not "use `TransactionTestCase` when you have database code", it is "use it when the *commit itself* is part of what you are testing", and that is a much smaller set. `setUpTestData` is the other piece worth knowing: it runs once per class inside an outer transaction, so shared fixtures are created a single time rather than per test — but the objects are shared between tests, which means mutating one in a test can leak into the next unless you re-fetch. On `SimpleTestCase`, treat the database ban as a design signal rather than a limitation: if a test you expected to be pure suddenly needs the database, something has reached further than it should.

python
class MyTests(TestCase):
    def test_side_effect(self):
        with self.captureOnCommitCallbacks(execute=True):
            do_the_thing()

What we're doing: Pick the cheapest class that can actually observe what each test is about.

orders/tests/test_orders.pypython
class PriceFormattingTests(SimpleTestCase):
    """No database at all — a query here would raise, which is the point."""

    def test_formats_currency(self):
        self.assertEqual(format_price(Decimal("40.5")), "£40.50")


class OrderTotalTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.customer = Customer.objects.create(name="Ana")   # once for the class

    def test_total_sums_lines(self):
        order = Order.objects.create(customer=self.customer)
        OrderLine.objects.create(order=order, price=Decimal("10"), quantity=2)
        self.assertEqual(order.total(), Decimal("20"))

    def test_confirmation_is_queued_on_commit(self):
        with self.captureOnCommitCallbacks(execute=True):
            place_order(self.customer)
        self.assertEqual(len(mail.outbox), 1)


class StockLockingTests(TransactionTestCase):
    """Needs real commits: two connections must actually contend."""

    def test_concurrent_reserve_does_not_oversell(self):
        Product.objects.create(pk=1, sku="X", stock=1)
        ...
1–2
`SimpleTestCase` turns "this should not need the database" into an enforced rule — if the test starts failing with a database error, the code under test grew a dependency it should not have.
9–11
`setUpTestData` creates the customer once per class rather than once per test. The objects are shared, so a test that mutates `cls.customer` must re-fetch rather than assume.
19–21
`captureOnCommitCallbacks(execute=True)` is what makes `on_commit` testable without paying for `TransactionTestCase` — it runs the pending callbacks and lets you assert on their effect.
24–28
The one test that genuinely needs real commits, because two connections have to contend for a lock. Everything else stays on the fast class.

Why this works: Three classes for three different needs: a database ban that catches accidental coupling, fast rollback isolation for the bulk of the suite, and real commits only where the commit is the thing being tested.

Asserting on an `on_commit` side effect inside a `TestCase`

Wrong

python
class SignupTests(TestCase):
    def test_sends_welcome_email(self):
        signup(email="a@example.com")          # enqueues via transaction.on_commit
        self.assertEqual(len(mail.outbox), 1)  # AssertionError: 0 != 1

Better

python
class SignupTests(TestCase):
    def test_sends_welcome_email(self):
        with self.captureOnCommitCallbacks(execute=True):
            signup(email="a@example.com")
        self.assertEqual(len(mail.outbox), 1)

What you see: The assertion fails with `0 != 1` even though the code is correct in production — or, worse, someone "fixes" it by asserting `0`, which makes the test pass while proving the opposite of what was intended.

Why: `TestCase` wraps each test in a transaction it rolls back, so `COMMIT` never happens and callbacks registered with `transaction.on_commit()` are never invoked. The failure looks like a bug in the code rather than in the test, which is how the wrong fix gets applied. `captureOnCommitCallbacks(execute=True)` runs them explicitly and keeps the test on the fast class.

The same on_commit test, under each class

TestCase — never commits

  • +Each test runs inside a transaction that is rolled back.
  • +Fast, and isolated without truncating anything.
  • +on_commit callbacks never fire — the assertion silently passes.
  • +A second connection cannot see the writes, so locking tests prove nothing.
  • +Right for almost every test that is not about committing.

TransactionTestCase — really commits

  • Writes are committed, then tables are truncated between tests.
  • on_commit callbacks fire exactly as they do in production.
  • A second connection sees the data, so select_for_update is testable.
  • Sequences reset, so tests assuming a specific pk break.
  • Noticeably slower — reserve it for tests about commits.
  • TestCase — never commits
    • Each test runs inside a transaction that is rolled back.
    • Fast, and isolated without truncating anything.
    • on_commit callbacks never fire — the assertion silently passes.
    • A second connection cannot see the writes, so locking tests prove nothing.
    • Right for almost every test that is not about committing.
  • TransactionTestCase — really commits
    • Writes are committed, then tables are truncated between tests.
    • on_commit callbacks fire exactly as they do in production.
    • A second connection sees the data, so select_for_update is testable.
    • Sequences reset, so tests assuming a specific pk break.
    • Noticeably slower — reserve it for tests about commits.

Which class, and what it costs

Which class, and what it costs
ClassDatabaseIsolation bySpeedUse for
`SimpleTestCase`**forbidden**nothing to isolatefastestpure functions, filters, URL resolution
`TestCase`transaction, rolled backrollbackfastalmost everything
`TransactionTestCase`real commitstruncate between testsslowon_commit, locking, cross-connection
`LiveServerTestCase`real commits + a live servertruncateslowestbrowser/end-to-end tests

Together

python
class OrderTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.customer = Customer.objects.create(name="Ana")   # once per class

Remember: `SimpleTestCase` forbids the database, which turns "this should be pure" into an enforced rule. `TestCase` wraps each test in a rolled-back transaction — fast, isolated, and the right default — but because it never commits, `on_commit` callbacks do not fire and other connections cannot see your writes, so use `captureOnCommitCallbacks(execute=True)` rather than switching classes. `TransactionTestCase` really commits and truncates, and is only worth its cost when the commit itself is what you are testing.

See also: the test client and what to test · pytest django and fixtures · on commit and transaction timing

The test client, and testing requests, URLs, templates, models and forms

coreintermediate

`self.client` is a fake browser: it builds a request, runs it through the full middleware and URL stack, and gives you the response — without a socket, a server, or a real HTTP connection. That means `client.get("/orders/")` exercises routing, middleware, permissions, the view and the template together. The response carries more than a body: `status_code`, `context` (what the template was rendered with), `templates` (which ones were used), and `redirect_chain` when you pass `follow=True`. Around that sit the narrower tests — resolve a URL with `reverse()` rather than hard-coding a path, assert a model method's return value directly, and test a form by instantiating it with data and checking `is_valid()` and `form.errors`.

Think of it as

Test at the narrowest layer that can actually fail. A model method that computes a total is a function — instantiate, call, assert, done; putting that through the test client adds routing, authentication and template rendering to a test that is about arithmetic, and when it breaks you have five suspects instead of one. Conversely, "does an anonymous user get redirected from this page" cannot be answered below the client, because the answer lives in the middleware and decorator stack. So the client is for behaviour that only exists once the pieces are assembled, and direct calls are for behaviour that exists on its own. Two habits make client tests worth having. Use `reverse()` instead of literal paths, so a URL change breaks one `urls.py` line rather than forty tests, and assert on `response.context` rather than on rendered HTML, because a template tweak should not fail a test about *what data the view chose*. `assertTemplateUsed` and `assertRedirects` exist for the same reason — they check the decision rather than the markup, which is what keeps a suite from being rewritten every time a designer moves a `<div>`.

python
response = self.client.get(reverse("order-detail", args=[order.pk]))
self.assertTemplateUsed(response, "orders/detail.html")
self.assertEqual(response.context["order"], order)

What we're doing: One test per layer, each at the narrowest level that can actually observe the behaviour.

orders/tests/test_views.pypython
class OrderTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("ana", password="x")
        cls.order = Order.objects.create(customer=cls.user, total=Decimal("40"))

    # Model: a direct call. No request, no template, one suspect.
    def test_total_includes_tax(self):
        self.assertEqual(self.order.total_with_tax(), Decimal("48.00"))

    # Form: instantiate with data. No view involved.
    def test_rejects_negative_quantity(self):
        form = OrderLineForm(data={"sku": "X", "quantity": -1})
        self.assertFalse(form.is_valid())
        self.assertIn("quantity", form.errors)

    # Middleware + permissions: only observable through the client.
    def test_anonymous_is_redirected_to_login(self):
        response = self.client.get(reverse("order-list"))
        self.assertRedirects(response, f"/accounts/login/?next={reverse('order-list')}")

    # The view's data choice: assert on context, not on HTML.
    def test_list_shows_only_own_orders(self):
        other = Order.objects.create(customer=User.objects.create_user("raj"))
        self.client.force_login(self.user)
        response = self.client.get(reverse("order-list"))
        self.assertQuerySetEqual(response.context["orders"], [self.order])
        self.assertNotIn(other, response.context["orders"])

    def test_uses_the_expected_template(self):
        self.client.force_login(self.user)
        response = self.client.get(reverse("order-list"))
        self.assertTemplateUsed(response, "orders/list.html")
7–9
Tax arithmetic tested as arithmetic. Routing it through the client would mean a routing change, a permission change or a template change could fail this test.
11–15
The form is instantiated directly, so the assertion is about the validation rule rather than about how a view happens to call it.
17–20
`assertRedirects` checks the destination *and* that the destination returns 200 — a redirect to a broken page would otherwise pass.
26–27
Asserting on `context["orders"]` tests the queryset the view chose. Searching the HTML for a customer name would fail the day someone changes the markup.
30–33
`assertTemplateUsed` names the decision. It survives every change to what is inside the template, which is the point.

Why this works: Each test has exactly one reason to fail, and the two that go through the client are the two whose behaviour genuinely does not exist until the stack is assembled.

Asserting on rendered HTML

Wrong

python
response = self.client.get(reverse("order-list"))
self.assertContains(response, '<td class="total">£40.00</td>')

Better

python
response = self.client.get(reverse("order-list"))
self.assertEqual(response.context["orders"][0].total, Decimal("40"))

What you see: A purely visual change — a class rename, a wrapper `<div>`, switching to a formatting filter — fails a dozen tests that have nothing to do with presentation, so the suite gets a reputation for crying wolf.

Why: The test was written about the data and expressed in terms of markup, so it now has two reasons to fail and only one of them is meaningful. `response.context` gives direct access to what the view decided, which is the actual subject. Assert on HTML only when the markup *is* the requirement — an escaping test, an accessibility attribute, a CSRF token being present.

What each kind of test actually exercises

client.get(reverse("order-list"))

the whole stack below, in one call — use it for behaviour that only exists assembled

URL resolution

reverse() and resolve() test this directly, and keep every other test free of literal paths

Middleware and permissions

only reachable through the client — redirects, 403s, and session handling live here

The view

assert on response.context: what the view CHOSE, not how the template drew it

Template rendering

assertTemplateUsed checks the decision; asserting on HTML couples the test to markup

Forms

Form(data=…).is_valid() and form.errors — a direct call, no request needed

Models

a plain function call. Routing this through the client adds four suspects to an arithmetic bug.

  1. client.get(reverse("order-list")) — the whole stack below, in one call — use it for behaviour that only exists assembled
  2. URL resolution — reverse() and resolve() test this directly, and keep every other test free of literal paths
  3. Middleware and permissions — only reachable through the client — redirects, 403s, and session handling live here
  4. The view — assert on response.context: what the view CHOSE, not how the template drew it
  5. Template rendering — assertTemplateUsed checks the decision; asserting on HTML couples the test to markup
  6. Forms — Form(data=…).is_valid() and form.errors — a direct call, no request needed
  7. Models — a plain function call. Routing this through the client adds four suspects to an arithmetic bug.

Test at the narrowest layer that can fail

Test at the narrowest layer that can fail
What you are testingTest it withNot with
A model method or propertya direct callthe test client
Form validation rules`Form(data=...)` + `is_valid()` + `errors`a POST through a view
A URL pattern resolves`reverse()` / `resolve()`a literal path in every test
A view chooses the right data`response.context`assertions on rendered HTML
Which template was used`assertTemplateUsed`searching the body for a string
Anonymous access is redirectedthe test clienta unit test of the decorator

Together

python
response = self.client.get(reverse("order-list"))
self.assertEqual(response.status_code, 200)
self.assertQuerySetEqual(response.context["orders"], [self.order])

Remember: The test client runs the whole middleware and URL stack in-process, so use it for behaviour that only exists once the pieces are assembled — redirects, permissions, which template rendered. Test models and forms with direct calls, where a failure has one suspect. Always `reverse()` rather than literal paths, and assert on `response.context` rather than on HTML, so a template change fails a template test and nothing else.

See also: the test case classes · pytest django and fixtures · testing contracts status codes and collections

Advertisement

The pytest ecosystem

pytest-django, fixtures and scopes, and the tools that stop tests repeating themselves.

pytest, pytest-django, fixtures, scopes, and database access

coreintermediate

pytest replaces `TestCase` subclasses with plain functions and `assert`, and **pytest-django** adds the Django-specific pieces. The most important is that **database access is blocked by default** — a test that queries without asking raises, so touching the database is always a deliberate choice. You ask with `@pytest.mark.django_db` or by requesting the `db` fixture. The plain form wraps each test in a transaction and rolls back, exactly like Django's `TestCase`; `@pytest.mark.django_db(transaction=True)` mirrors `TransactionTestCase`, flushing between tests. Fixtures replace `setUp`: a function decorated with `@pytest.fixture` that other tests request by name, with a `scope` controlling how often it is rebuilt.

Think of it as

The blocked-by-default database is the design decision worth understanding, because it inverts Django's own default and does so deliberately: a test that does not need the database should not be able to reach it accidentally, and one that does should say so where a reader can see it. That turns the marker into documentation — you can tell a pure test from an integration test by looking at its decorator rather than by running it. Fixtures are the other shift, and the useful frame is dependency injection: a test declares what it needs by naming it as a parameter, and pytest builds exactly that graph. Scope then answers "how often is this rebuilt", and the trap is that scope and database transactions interact badly — the `db` fixture is function-scoped, so a `session`-scoped fixture cannot request it, and objects created in a session fixture would not be rolled back between tests anyway. So the rule is: session scope for expensive, read-only, non-database things (a parsed config, a compiled template, a fake HTTP server) and function scope for anything touching data. When a whole session genuinely needs seeded data, the supported route is overriding `django_db_setup` and using `django_db_blocker.unblock()`, which is the one place session-scoped database access is legitimate.

python
@pytest.mark.django_db
def test_thing(client, settings):
    settings.FEATURE_X = True          # restored automatically after the test
    assert client.get("/").status_code == 200

What we're doing: A conftest that composes fixtures, seeds the session once legitimately, and keeps the database opt-in.

conftest.py + orders/tests/test_orders.pypython
# conftest.py
@pytest.fixture(scope="session")
def django_db_setup(django_db_setup, django_db_blocker):
    """Session-wide reference data — the one place session-scoped DB access is legal."""
    with django_db_blocker.unblock():
        call_command("loaddata", "countries.json")


@pytest.fixture
def customer(db):                       # function-scoped: db is function-scoped
    return Customer.objects.create(name="Ana", country="GB")


@pytest.fixture
def paid_order(customer):               # composes: no need to repeat customer setup
    return Order.objects.create(customer=customer, status="paid", total=Decimal("40"))


@pytest.fixture(scope="session")
def price_table():                      # expensive, read-only, no database
    return parse_price_table(Path("fixtures/prices.csv"))


# test_orders.py
def test_formats_price():               # no marker: cannot touch the database
    assert format_price(Decimal("40.5")) == "£40.50"


@pytest.mark.django_db
def test_list_shows_only_own_orders(client, paid_order, django_assert_num_queries):
    client.force_login(paid_order.customer)
    with django_assert_num_queries(3):
        response = client.get(reverse("order-list"))
    assert list(response.context["orders"]) == [paid_order]


@pytest.mark.parametrize("status,expected", [("paid", 200), ("draft", 404)])
@pytest.mark.django_db
def test_detail_visibility(client, customer, status, expected):
    order = Order.objects.create(customer=customer, status=status)
    client.force_login(customer)
    assert client.get(reverse("order-detail", args=[order.pk])).status_code == expected
2–6
Overriding `django_db_setup` and unblocking is the supported way to seed once per run. Any other session-scoped database access is both unsupported and unrolled-back.
10
Requesting `db` inside the fixture rather than marking every test — a test that uses `customer` gets database access transitively, which is both convenient and still explicit.
14–16
Fixtures composing fixtures. `paid_order` never repeats customer setup, and changing how a customer is built updates every test at once.
19–21
Session scope is safe here precisely because there is no database involved — parse the file once and share it across the whole run.
32–33
`django_assert_num_queries` turns an N+1 regression into a failing test, which is the cheapest performance guard a Django suite can have.

Why this works: The database stays opt-in and visible, expensive non-database work is built once, and the query-count assertion catches the performance regression that no functional test would notice.

Requesting `db` from a session-scoped fixture

Wrong

python
@pytest.fixture(scope="session")
def customer(db):                       # ScopeMismatch
    return Customer.objects.create(name="Ana")

Better

python
@pytest.fixture
def customer(db):                       # function scope, rolled back per test
    return Customer.objects.create(name="Ana")

What you see: `ScopeMismatch: You tried to access the function scoped fixture db with a session scoped request object` — raised at collection, before any test runs, so the whole suite fails to start.

Why: A fixture cannot depend on one with a narrower scope, and `db` is function-scoped because its isolation *is* the per-test transaction. Even if the scopes were compatible, a session-scoped object created in the database would not be rolled back between tests, so the first test to mutate it would leak into every later one. Seeding for a whole session has its own supported route through `django_db_setup` and `django_db_blocker`.

From a blocked database to a test that says what it needs

1 · Blocked by default

A query with no marker and no db fixture raises. Reaching the database is always a visible, deliberate choice.

2 · Ask for it explicitly

The marker wraps the test in a transaction and rolls it back — the same isolation Django's TestCase provides.

3 · Declare dependencies as fixtures

A test names what it needs and pytest builds the graph. Fixtures can request other fixtures, so setup composes instead of repeating.

4 · Escalate only when the commit matters

transaction=True gives real commits and flushes between tests — needed for on_commit and locking, and noticeably slower.

  1. 1 · Blocked by default — A query with no marker and no db fixture raises. Reaching the database is always a visible, deliberate choice.
  2. 2 · Ask for it explicitly — The marker wraps the test in a transaction and rolls it back — the same isolation Django's TestCase provides.
  3. 3 · Declare dependencies as fixtures — A test names what it needs and pytest builds the graph. Fixtures can request other fixtures, so setup composes instead of repeating.
  4. 4 · Escalate only when the commit matters — transaction=True gives real commits and flushes between tests — needed for on_commit and locking, and noticeably slower.

Fixture scopes, and what belongs in each

Fixture scopes, and what belongs in each
ScopeRebuiltMay touch the database?Good for
`function` (default)every testyes — via `db`model instances, anything mutated
`class`once per classno (`db` is function-scoped)shared setup for a group of pure tests
`module`once per filenoa parsed file, a compiled template
`session`once per runonly via `django_db_blocker`a fake HTTP server, an expensive computation

Together

python
@pytest.fixture
def order(db, customer):
    return Order.objects.create(customer=customer, total=Decimal("40"))

Remember: pytest-django blocks the database by default, so `@pytest.mark.django_db` (or the `db` fixture) is both permission and documentation. The plain form is a rolled-back transaction like `TestCase`; `transaction=True` mirrors `TransactionTestCase` and costs real time, so use it only when the commit is the subject. `db` is function-scoped, so no session-scoped fixture may request it — reserve session scope for expensive read-only work with no database, and seed a whole run through `django_db_setup` plus `django_db_blocker.unblock()`.

See also: parameterization marks async and factories · the test case classes · the n plus 1 pattern

Parameterization, marks, async tests, and factories

standardintermediate

`@pytest.mark.parametrize` runs one test body against many inputs, and each case is reported separately — so a failure names the exact input rather than "the test failed". **Marks** are labels: `@pytest.mark.slow` plus `-m "not slow"` splits a suite, and `django_db` is itself a mark. **Async tests** need `pytest-asyncio` (`@pytest.mark.asyncio`) and, in Django, `@pytest.mark.django_db` with async-safe database access — the ordinary ORM inside an async test raises `SynchronousOnlyOperation` exactly as it does in production. **Factories** (`factory_boy`) replace hand-built fixtures: `OrderFactory(status="paid")` builds a valid object with every required field filled and only the ones you care about specified.

Think of it as

Parameterization and factories both attack the same problem from different sides: test code that says more than the test is about. A hand-written setup block that creates a customer, an address, a product and three order lines to test one status transition buries the single relevant detail in twenty irrelevant ones — and when a required field is added to any of those models, every such block breaks at once. A factory moves the irrelevant details behind a default, so the test reads `OrderFactory(status="paid")` and the reader sees exactly what mattered. Parameterization does the same to repetition: four near-identical tests differing by one value become one test and a table of values, and the table itself documents the boundaries you thought about. The discipline with both is to keep them honest — a factory whose defaults are unrealistic (every user named "test", every price zero) hides bugs that real data would surface, and a parametrize list that grows to thirty cases usually means the loop is testing the framework rather than your logic. Marks are the smallest of the four and the most operationally useful: labelling the slow tests is what lets a pre-commit hook run the fast ones.

python
class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order

    customer = factory.SubFactory(CustomerFactory)
    total = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)

What we're doing: Factories with realistic defaults, a parametrized boundary table, a registered mark, and one async test.

orders/tests/factories.py + orders/tests/test_orders.py + pytest.inipython
# factories.py
class CustomerFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Customer

    name = factory.Faker("name")               # realistic, not "test"
    email = factory.Sequence(lambda n: f"customer{n}@example.com")
    country = factory.Iterator(["GB", "IN", "US"])


class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order

    customer = factory.SubFactory(CustomerFactory)
    status = "draft"
    total = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)


# test_orders.py
@pytest.mark.django_db
@pytest.mark.parametrize("status,expected", [
    ("draft", 404),          # not yet visible
    ("paid", 200),
    ("cancelled", 410),      # gone, and deliberately distinguishable from 404
])
def test_detail_visibility(client, status, expected):
    order = OrderFactory(status=status)
    client.force_login(order.customer)
    assert client.get(order.get_absolute_url()).status_code == expected


@pytest.mark.slow                              # registered in pytest.ini
@pytest.mark.django_db(transaction=True)
def test_full_export(client):
    ...


@pytest.mark.asyncio
@pytest.mark.django_db
async def test_async_detail(async_client):
    order = await sync_to_async(OrderFactory)(status="paid")
    response = await async_client.get(order.get_absolute_url())
    assert response.status_code == 200


# pytest.ini
#   [pytest]
#   markers =
#       slow: takes more than a second; excluded by "-m 'not slow'"
6–8
`Faker`, `Sequence` and `Iterator` give realistic, unique values. Defaults of `"test"` and `0` pass every test and hide the bugs that a real name with an apostrophe or a non-GB country would find.
15–17
`SubFactory` builds the relation automatically, so a test needing an order never has to know a customer exists — which is the whole point.
22–26
The table is the documentation: it shows the three states someone thought about, and 410 rather than 404 for cancelled is a deliberate distinction a prose test would have buried.
33–34
Two marks: `slow` for filtering and `transaction=True` because this one genuinely commits. Registering `slow` in `pytest.ini` is what makes `--strict-markers` catch a typo.
39–42
An async test needs `sync_to_async` around the factory — the ORM is no safer inside a test than inside a view, and raises the same `SynchronousOnlyOperation`.

Why this works: Every irrelevant detail lives in the factory, every relevant one is in the parametrize table, and the marks let the fast subset run on every commit.

The same three cases, written twice

Hand-built setup, repeated

  • +Three near-identical tests; the difference is one word each.
  • +Every required field is spelled out, burying the relevant one.
  • +Adding a non-nullable column to Customer breaks all three.
  • +A failure says "test_paid_is_visible failed" and nothing more.
  • +The reader has to diff the tests to see what varies.

Factory plus parametrize

  • One body, one table — the table IS the list of cases considered.
  • The factory fills everything irrelevant, so only status appears.
  • A new required field is one change, in the factory.
  • A failure reports test_visibility[cancelled-410]: the exact case.
  • Adding a fourth case is one line.
  • Hand-built setup, repeated
    • Three near-identical tests; the difference is one word each.
    • Every required field is spelled out, burying the relevant one.
    • Adding a non-nullable column to Customer breaks all three.
    • A failure says "test_paid_is_visible failed" and nothing more.
    • The reader has to diff the tests to see what varies.
  • Factory plus parametrize
    • One body, one table — the table IS the list of cases considered.
    • The factory fills everything irrelevant, so only status appears.
    • A new required field is one change, in the factory.
    • A failure reports test_visibility[cancelled-410]: the exact case.
    • Adding a fourth case is one line.

Four tools, four kinds of repetition removed

Four tools, four kinds of repetition removed
ToolRemovesFailure mode it prevents
`parametrize`near-identical test bodies"the test failed" without saying for which input
Marksrunning everything all the timea slow suite nobody runs before pushing
`pytest-asyncio`manual event-loop plumbingasync code that is only ever tested synchronously
Factoriessetup that buries the relevant detaila new required field breaking every test file

Together

python
@pytest.mark.parametrize("status,expected", [
    ("draft", 404), ("paid", 200), ("cancelled", 410),
])
def test_visibility(client, status, expected): ...

Remember: `parametrize` turns near-identical tests into one body plus a table, and the table documents the boundaries you considered — each case is reported by name, so a failure identifies the input. Register custom marks so `--strict-markers` catches a typo rather than silently skipping. Async tests need `pytest-asyncio`, and the ORM inside one raises `SynchronousOnlyOperation` exactly as in production. Factories fill every irrelevant field so a test shows only what matters — but keep the defaults realistic, or they hide the bugs real data would find.

See also: pytest django and fixtures · the test client and what to test · the sync async bridge

Advertisement