Django Advanced

Testing Django at Scale: pytest, factory_boy, Hypothesis, and Mutation Testing

Move from a slow, brittle test suite to a fast, trustworthy one. Master pytest-django fixtures, generate data with factory_boy, find edge cases automatically with property-based testing, and measure real coverage with mutation testing.

DjangoZen Team Jun 06, 2026 3 min read 2 views

A test suite that's slow gets skipped, and one that passes while bugs ship is worse than none — it lulls you. Real confidence comes from fast tests, realistic data, automatic edge-case discovery, and a way to verify your tests actually catch bugs. Here's the modern Django testing stack.

pytest-django over unittest

pytest gives you plain assert statements, powerful fixtures, and parametrization. Configure it and use markers for DB access:

pip install pytest-django pytest-xdist
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = djzen.settings
addopts = --reuse-db -n auto      # reuse test DB, run on all cores

--reuse-db skips recreating the schema each run; -n auto (pytest-xdist) parallelizes across CPU cores. Together they often cut suite time by 5–10×.

factory_boy for realistic data

Fixtures rot and hand-built objects are tedious. Factories generate valid, varied objects on demand:

import factory

class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order
    customer = factory.SubFactory(UserFactory)
    total = factory.Faker("pydecimal", left_digits=3, positive=True)

    @factory.post_generation
    def items(self, create, extracted, **kw):
        if create:
            OrderItemFactory.create_batch(extracted or 3, order=self)
order = OrderFactory()                 # fully built, with 3 items
big = OrderFactory(total=Decimal("9999"))   # override what you care about

Property-based testing with Hypothesis

Example-based tests only check the cases you imagined. Hypothesis generates hundreds of inputs and finds the ones you didn't — then shrinks a failure to the minimal reproducing case:

from hypothesis import given, strategies as st

@given(st.decimals(min_value=0, max_value=10**6, places=2),
       st.integers(min_value=0, max_value=100))
def test_discount_never_exceeds_total(price, percent):
    result = apply_discount(price, percent)
    assert 0 <= result <= price        # an invariant, for ALL inputs

You assert properties ("discount never makes the price negative or higher than the original") instead of specific numbers. This is where the gnarly edge-case bugs surface.

Mutation testing — testing your tests

100% coverage means every line ran, not that a bug in it would be caught. Mutation testing deliberately injects bugs (flips < to <=, + to -) and checks whether your suite fails. A surviving mutant is a blind spot.

pip install mutmut
mutmut run
mutmut results      # surviving mutants = untested behavior

Summary

A trustworthy suite is fast (pytest with --reuse-db -n auto), fed by realistic factory data, hardened by property-based tests that hunt edge cases you'd never write by hand, and validated by mutation testing that proves the tests actually bite. Coverage tells you what ran; mutation testing tells you what's protected — aim for the latter.