Hypothesis & Fuzzing

Property-Testing Django Models with Hypothesis

Django models encode constraints in field definitions — lengths, nullability, choices, validators — and business rules in methods and clean(). Example-based tests exercise both with a handful of hand-written instances, usually the same three or four values everyone copies between tests. hypothesis.extra.django generates instances from the model definition itself, respecting every declared constraint, and runs each example in its own transaction so the database stays clean between them.

That combination is unusually productive. The constraints come for free from the model, the isolation comes for free from the test case, and the properties worth asserting — that saved instances validate, that serializers round-trip, that derived fields agree with their inputs — are short and general. Bugs found this way tend to be the ones hand-written fixtures never reach: a name with an apostrophe, an empty optional field, a boundary length. They also tend to be the bugs users report first, because real data is far more varied than any fixture file: customers type apostrophes, leave optional fields empty and hit length limits constantly, and a model layer that has only ever been tested with 'Jane Smith' is untested against most of the input it will actually receive.

Prerequisites

Solution

Python
from hypothesis import given, strategies as st
from hypothesis.extra.django import TestCase, from_model

from shop.models import Customer, Order


customers = from_model(
    Customer,
    email=st.emails(),                                  # narrower than the CharField
    country=st.sampled_from(["GB", "DE", "FR", "US"]),
)
orders = from_model(
    Order,
    customer=customers,                                 # required foreign key
    total_minor=st.integers(min_value=0, max_value=10_000_000),
)


class CustomerProperties(TestCase):
    """Each example runs in its own atomic block, rolled back afterwards."""

    @given(customers)
    def test_saved_customers_pass_full_clean(self, customer):
        customer.full_clean()                           # raises if invalid

    @given(customers)
    def test_display_name_is_never_empty(self, customer):
        assert customer.display_name().strip()


class OrderProperties(TestCase):
    @given(orders)
    def test_serializer_round_trips(self, order):
        data = OrderSerializer(order).data
        restored = OrderSerializer(data=data)
        assert restored.is_valid(), restored.errors
        assert restored.validated_data["total_minor"] == order.total_minor
From model definition to generated, isolated examples from_model reads the model's field definitions — max length, nullability, choices and validators — and combines them with any explicit overrides to produce a strategy. Hypothesis's Django TestCase runs each generated example inside its own atomic block and rolls it back, so every example starts from the same empty database. Constraints from the model, isolation from the test case model fields max_length, null, blank choices, validators field types from_model(…) + domain overrides + related strategies valid by construction per-example atomic example saved, tested, rolled back next example starts clean Using django.test.TestCase instead shares one transaction across every example.
The model definition is the specification the strategy is derived from, so tightening a field in the model tightens the generated data automatically.

Why this works

from_model builds a strategy per field from the field's own definition — a CharField(max_length=20) becomes text of at most twenty characters, a field with choices becomes a sample from those choices, a nullable field sometimes generates None — and saves the resulting instance, so the object the test receives is a real row with a real primary key. Keyword arguments override individual fields where the domain is narrower than the column type, such as an email address stored in a plain CharField.

hypothesis.extra.django.TestCase wraps each example, not each test method, in a transaction that is rolled back afterwards. That is the isolation plain Django test cases cannot give a property test, because their transaction spans the whole method — every example in it would see rows created by the previous ones, exactly the leak the function-scoped-fixture health check warns about.

Edge cases and failure modes

  • Using django.test.TestCase. Examples share one transaction and see each other's rows. Use the Hypothesis subclass.
  • Unique fields colliding. A unique field with a small value space can collide across generated instances within one example. Widen the strategy or generate deterministic unique values.
  • Custom field types. Fields from_model does not recognise need an explicit strategy passed as a keyword, or registered once with register_field_strategy.
  • Slow examples. Every example writes to the database. Keep max_examples modest for model tests and disable the per-example deadline where queries vary in latency.
  • TransactionTestCase needs. Code that must see committed data from another connection cannot run inside the rollback. Use hypothesis.extra.django.TransactionTestCase and accept the slower truncation.

Properties worth asserting about models

Model tests often stall at "what would I even check?", because the obvious assertions are about specific values. A handful of general properties apply to nearly every model and find real bugs.

Saved instances are valid. Anything that can be saved should pass full_clean(). A failure means the database accepts values the application's own validation rejects — a missing validator, a clean() method assuming a field is set, a constraint enforced in one place and not the other.

Serialisation round-trips. Serialise with the API serializer, deserialize the result, and the data must validate and match. Failures here are among the most common and most user-visible: a decimal rendered with the wrong precision, a timezone dropped, a nullable field rejected on the way back in.

Derived values agree with their inputs. Totals equal the sum of their lines, status fields agree with timestamps, slugs are derived from names. Generating many combinations of inputs catches the rounding and ordering errors in those derivations.

Display methods never fail. __str__, display_name, admin list columns — methods that should handle every valid instance, including those with empty optional fields. They are rarely tested and frequently raise on None.

Four general properties for Django models Four cards. Saved instances pass full_clean. Serializer output round-trips and validates. Derived fields agree with their inputs. Display methods succeed for every valid instance including empty optional fields. Each is a short assertion checked against every generated instance. Checks that apply to almost any model saved ⇒ valid instance.full_clean() never raises serializer round trip out and back in, still valid, same values derived fields agree total == sum(lines), slug from name display never fails __str__ and admin columns on empty fields
Four short tests per model, each checked against hundreds of generated instances, cover more than the usual pile of hand-picked fixtures.

Keeping model properties fast

Every generated example writes at least one row, and related-object strategies write several, so model properties are among the slowest Hypothesis tests a project will have. The budget needs managing deliberately rather than left at the default.

The first lever is max_examples. Fifty examples per model property is usually enough to surface the field-level bugs these tests target — empty strings, boundary lengths, missing optional values — because those cases are generated early and often. A nightly profile can raise the budget for deeper search without slowing every pull request.

The second is avoiding persistence where it is not needed. A property about a display method or a pure computation does not need a saved row; from_model always saves, but st.builds(Model, ...) with field strategies constructs an unsaved instance in memory, which is an order of magnitude faster. Reserve from_model for properties that genuinely involve the database — uniqueness, foreign keys, query behaviour — and use unsaved instances for everything else.

The third is disabling the per-example deadline. Database writes vary in latency, and the default deadline turns an occasional slow insert into a spurious failure. @settings(deadline=None) on model test classes removes that noise without weakening the properties themselves. Together the three changes usually bring a model test class from tens of seconds to a few.

Registering strategies once for the whole project

Passing the same overrides to from_model in every test module duplicates the domain rules and lets them drift. Hypothesis offers two registration points that make a model's strategy a single, shared definition.

register_field_strategy teaches Hypothesis how to generate values for a custom field type — a MoneyField, an encrypted field, a field backed by a third-party type — once, for the whole project. After registration, every from_model call handles that field automatically, and nothing in the tests needs to know it is special.

A module of named model strategies does the same for domain constraints. shop/testing/strategies.py exports customers, orders and paid_orders, each built from from_model with the right overrides, and every test imports them. When the domain changes — a new country is supported, the maximum order total is raised — one strategy changes and every property that uses it follows.

Python
# shop/testing/strategies.py
from hypothesis import strategies as st
from hypothesis.extra.django import from_model, register_field_strategy

from shop.fields import MoneyField
from shop.models import Customer, Order

register_field_strategy(MoneyField, st.integers(min_value=0, max_value=10_000_000))

customers = from_model(Customer, email=st.emails(),
                       country=st.sampled_from(["GB", "DE", "FR", "US"]))
orders = from_model(Order, customer=customers)
paid_orders = from_model(Order, customer=customers, status=st.just("paid"))

The module is the same idea as a factory module for example-based tests, and it belongs next to the models for the same reason: a change to a model's constraints and a change to its strategy should be reviewed together. A test that checks every generated instance passes full_clean() keeps them honest, failing the day a model gains a constraint its strategy does not respect.

Shared strategies for a Django project A single strategies module registers custom field strategies once and exports named model strategies with domain overrides. Every test module imports from it, so a change to a domain rule is made in one place and all properties that depend on it follow. One definition of valid data per model shop/testing/strategies.py register_field_strategy(…) customers, orders, paid_orders test_customers.py test_orders.py test_serializers.py
A new supported country is a one-line change in the module, and every property picks it up on its next run.

Frequently Asked Questions

Which TestCase should Hypothesis tests use in Django?hypothesis.extra.django.TestCase, not django.test.TestCase. It wraps each generated example in its own atomic block and rolls it back, so rows created by one example never appear in the next. With plain django.test.TestCase the transaction spans the whole test method and examples leak into each other.

How does from_model know what values are valid? It inspects each field: max_length, null, blank, choices, validators, and the field type. Generated instances satisfy those constraints and are saved to the database, so a CharField with max_length=20 never receives 21 characters.

How do I generate a model with a required foreign key? Pass a strategy for the related field explicitly, usually from_model of the related model: from_model(Order, customer=from_model(Customer)). Hypothesis creates the related row first and links it.

← Back to Hypothesis Integration with pytest & Frameworks