hypothesis-django: Property-Based Testing for Django Models and Views

hypothesis-django: Property-Based Testing for Django Models and Views

Hypothesis is a property-based testing library for Python. Instead of writing specific test cases, you describe the shape of your input and Hypothesis generates hundreds of examples automatically — including edge cases you wouldn't think to write manually. hypothesis-django is the Django integration that handles database transactions, model validation, and test database setup.

Installation

pip install hypothesis[django]

In settings.py or conftest.py:

from hypothesis import settings

settings.register_profile('ci', max_examples=100)
settings.register_profile('dev', max_examples=10)
settings.load_profile('ci')

Basic Property Test

A property test makes a claim that should hold for all valid inputs:

from hypothesis import given, strategies as st
from myapp.utils import slugify

@given(st.text(min_size=1, max_size=200))
def test_slugify_always_returns_lowercase(text):
    result = slugify(text)
    assert result == result.lower()

@given(st.text(min_size=1, max_size=200))
def test_slugify_contains_no_spaces(text):
    result = slugify(text)
    assert ' ' not in result

When a test fails, Hypothesis shrinks the failing example to the smallest input that still fails — you get text='\n' rather than a random 50-character string.

Django Integration

Use @pytest.mark.django_db with @given for database tests:

import pytest
from hypothesis import given, strategies as st
from hypothesis.extra.django import from_model
from myapp.models import Article

@pytest.mark.django_db
@given(from_model(Article, title=st.text(min_size=1, max_size=255)))
def test_article_str_includes_title(article):
    assert article.title in str(article)

from_model(Article) generates valid Article instances by:

  1. Inferring strategies from field types (CharField → st.text, IntegerField → st.integers)
  2. Inserting records into the test database
  3. Rolling back the transaction after each test function (not after each example — see below)

Transaction Handling

Hypothesis runs many examples per test function. The database state from one example persists to the next within the same function call. This is different from regular pytest-django where each test function gets a clean database.

Two ways to handle this:

Option 1: Use @settings(suppress_health_check=[HealthCheck.too_slow]) with manual cleanup

Option 2: Use hypothesis.extra.django's TestCase:

from hypothesis.extra.django import TestCase
from hypothesis import given, strategies as st
from hypothesis.extra.django import from_model
from myapp.models import Tag

class TagPropertyTests(TestCase):
    @given(from_model(Tag, name=st.text(min_size=1, max_size=50)))
    def test_tag_name_slug_is_unique_per_name(self, tag):
        # Each example runs in a savepoint; rolled back after each example
        self.assertIsNotNone(tag.slug)

hypothesis.extra.django.TestCase wraps each example in a database savepoint and rolls it back, giving you a clean state per example. This is the recommended approach for model tests.

from_model Strategies

from_model infers strategies from field definitions:

from hypothesis.extra.django import from_model
from myapp.models import Order

# Infers: id (int), amount (decimal), currency (text), status (text)
order_strategy = from_model(Order)

# Override specific fields
order_strategy = from_model(
    Order,
    amount=st.decimals(min_value='0.01', max_value='9999.99', places=2),
    currency=st.sampled_from(['usd', 'eur', 'gbp']),
    status=st.sampled_from(['pending', 'paid', 'refunded']),
)

For fields Hypothesis can't infer automatically (custom validators, GenericForeignKey), provide explicit strategies.

Testing Model Validation

Property tests are excellent for finding validation edge cases:

from hypothesis.extra.django import TestCase
from hypothesis import given, strategies as st, assume
from myapp.models import UserProfile

class UserProfileValidationTests(TestCase):

    @given(
        username=st.text(
            alphabet=st.characters(whitelist_categories=('Ll', 'Lu', 'Nd')),
            min_size=3,
            max_size=30
        )
    )
    def test_valid_username_passes_validation(self, username):
        profile = UserProfile(username=username)
        try:
            profile.full_clean()
        except ValidationError as e:
            self.fail(f'Unexpected validation error for username={username!r}: {e}')

    @given(email=st.emails())
    def test_valid_email_is_accepted(self, email):
        profile = UserProfile(username='testuser', email=email)
        # Should not raise for valid emails
        try:
            profile.clean_fields(exclude=['username'])
        except ValidationError as e:
            if 'email' in e.message_dict:
                self.fail(f'Valid email {email!r} was rejected: {e}')

assume() skips an example without counting it as a failure:

from hypothesis import assume

@given(st.text())
def test_empty_string_not_allowed(text):
    assume(len(text) > 0)  # Skip empty strings — tested elsewhere
    result = process(text)
    assert result is not None

Testing Views and APIs

Use Hypothesis with Django's test client to property-test API endpoints:

from hypothesis.extra.django import TestCase
from hypothesis import given, strategies as st
from django.urls import reverse
import json

class SearchAPITests(TestCase):

    @given(query=st.text(max_size=100))
    def test_search_endpoint_never_returns_500(self, query):
        response = self.client.get(
            reverse('api:search'),
            {'q': query},
        )
        self.assertNotEqual(response.status_code, 500)

    @given(
        page=st.integers(min_value=1, max_value=100),
        page_size=st.integers(min_value=1, max_value=50),
    )
    def test_pagination_params_always_return_200_or_404(self, page, page_size):
        response = self.client.get(
            reverse('api:articles'),
            {'page': page, 'page_size': page_size},
        )
        self.assertIn(response.status_code, [200, 404])

This pattern finds input combinations that cause 500 errors — crashes from unhandled edge cases in query parsing, ORM filters, or serializers.

Stateful Testing

Stateful tests model sequences of operations. Use RuleBasedStateMachine:

from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
from hypothesis import given, strategies as st
from myapp.models import ShoppingCart, Product

class ShoppingCartMachine(RuleBasedStateMachine):

    def __init__(self):
        super().__init__()
        self.cart = ShoppingCart.objects.create()
        self.expected_count = 0

    @rule(quantity=st.integers(min_value=1, max_value=10))
    def add_item(self, quantity):
        product = Product.objects.create(name='Test', price='9.99')
        self.cart.add_item(product, quantity)
        self.expected_count += quantity

    @rule()
    def clear_cart(self):
        self.cart.clear()
        self.expected_count = 0

    @invariant()
    def cart_total_matches_item_count(self):
        assert self.cart.total_quantity() == self.expected_count

TestShoppingCart = ShoppingCartMachine.TestCase

Hypothesis generates random sequences of add_item and clear_cart calls and checks the invariant after each step.

Settings

from hypothesis import settings, HealthCheck

@given(...)
@settings(
    max_examples=200,          # More examples for critical paths
    deadline=2000,             # 2 second deadline per example (ms)
    suppress_health_check=[HealthCheck.too_slow],  # For slow DB tests
)
def test_critical_path(data):
    ...

Common settings:

  • max_examples: how many passing examples to generate (default: 100)
  • deadline: max milliseconds per example before Hypothesis reports a timing issue
  • deriving_strategies: True to auto-infer strategies from type hints

Combining with factory_boy

from hypothesis import given, strategies as st
from hypothesis.extra.django import from_model
from myapp.tests.factories import UserFactory

@pytest.mark.django_db
@given(
    user=from_model(User, email=st.emails()),
    discount=st.floats(min_value=0, max_value=1),
)
def test_discount_calculation_never_negative(user, discount):
    price = Decimal('100.00')
    final_price = price * (1 - Decimal(str(discount)))
    assert final_price >= 0

Key Points

  • @given describes input shapes; Hypothesis generates examples and shrinks failures to minimal cases
  • from_model(MyModel) infers Django field strategies automatically; override specific fields as needed
  • Use hypothesis.extra.django.TestCase for per-example database savepoints — cleaner than @pytest.mark.django_db for stateful tests
  • assume() skips examples that don't satisfy preconditions
  • Property-test API views with .assertNotEqual(status_code, 500) — finds crashes from unexpected inputs
  • RuleBasedStateMachine tests sequences of operations with invariant checks between steps
  • Set max_examples higher for critical business logic; lower for slow integration tests

Read more