🎉 Heading to EuroPython 2026? Check out my poster on Django TDD Patterns & read the full visual field guide here.

Django TDD Patterns: A Visual Field Guide

A comprehensive deep-dive into Test-Driven Development in Django. Master factory patterns, permission matrices, and mocking boundaries to build resilient codebases.

Django TDD Patterns: A Visual Field Guide
Contents

Most Django testing tutorials stop at the basics: “write a test, watch it fail, make it pass, refactor.” While this sounds great in theory, real-world Django applications are infinitely more complex.

Is TDD even the right framework for apps that are mostly CRUD and ORM glue? Honestly, the case is weaker there—you already know the shape of the interface. TDD earns its keep at the seams: tangled model relationships, multi-layered permission systems, state transitions, and third-party API integrations. That is exactly why this guide focuses there instead of trivial model CRUD.

If you are attending EuroPython 2026 in KrakĂłw, I invite you to come find my poster presentation on this exact topic. We’ll be walking through a visual decision-tree for structuring Django tests.

Whether you’re at the conference or not, this guide serves as the ultimate companion piece—a deep dive into the patterns and practices that will transform your Django test suite from a brittle, slow liability into a fast, reliable safety net. All of the concepts here are framework-agnostic at their core; while the examples use DRF and pytest, the same logic applies perfectly to plain Django views and the standard unittest library.


1. The Factory Pattern Taxonomy

One of the most critical aspects of TDD in Django is generating realistic test data efficiently. If you rely on Django’s built-in JSON .fixture files, you are setting yourself up for an unmaintainable test suite. Fixtures are static snapshots; every migration risks breaking them silently, and they naturally encourage over-fetching data.

This is where factory_boy and faker shine. factory_boy is a library that generates test objects on demand using your actual model fields. While tools like model_bakery might be faster to start with, factory_boy gives you the declarative control—traits, hooks, explicit strategies—that matters at scale.

To keep things DRY across hundreds of models, maintain one factories.py per app, colocated with your models. Shared cross-app concerns (like “a paid organization”) should become traits, not entirely new factories.

Understanding the Relations

When modeling factories, you’re essentially recreating your database schema in memory. Choosing the wrong relation type leads to N+1 test queries and test-data explosions. (It’s a good practice to integrate assertNumQueries or django-perf-rec directly into your TDD loop to catch query regressions during the red-green-refactor cycle, rather than finding them three weeks later in a profiler).

SubFactory

Use SubFactory when a model has a required ForeignKey to another model. Even if a SubFactory chain goes 4 or 5 levels deep, it won’t slow down your tests—provided you use .build() instead of .create(), since depth doesn’t cost I/O if you aren’t hitting the database.

class AuthorFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Author
    name = factory.Faker('name')

class BookFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Book
    
    title = factory.Faker('sentence')
    author = factory.SubFactory(AuthorFactory) 

RelatedFactory & Many-to-Many

Use RelatedFactory for reverse ForeignKey relationships or OneToOne fields where the related object should be created after the main object. If a test fails, RelatedFactory won’t leave orphans in your database because the entire test runs inside a rolled-back transaction.

For ManyToMany fields, you must use @factory.post_generation hooks, because you can’t set an M2M relationship at construction time until the parent object has a primary key.

class AuthorWithProfileFactory(AuthorFactory):
    profile = factory.RelatedFactory(
        'myapp.factories.AuthorProfileFactory',
        factory_related_name='author'
    )

LazyAttribute

Perfect for fields that depend on other fields within the same factory. A LazyAttribute can even reference another LazyAttribute via the o parameter, as long as it is defined after the one it references.

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = 'auth.User'
        
    first_name = factory.Faker('first_name')
    last_name = factory.Faker('last_name')
    
    @factory.lazy_attribute
    def email(self, o):
        return f'{o.first_name.lower()}.{o.last_name.lower()}@example.com'

The Golden Rule: Create vs. Build

A massive performance killer is hitting the database unnecessarily.

How do you actually decide between build() and create()? Ask yourself: Does this test assert anything that requires a database round-trip (a query, a signal, a constraint)? If no, use build(). You should only reach for create() to test something the ORM does, not something your Python code does. build() is the default; create() is the exception you have to justify.


2. Testing Permission Layers Without Drowning

Modern Django apps often utilize multiple permission layers stacked together: view-level (DRF permissions) and object-level (via django-guardian or row-level security). When layers stack, precedence bugs are a real risk—a bug in one layer can easily hide behind the other because the app only sees whichever is more restrictive. You must test each layer in isolation first.

For Row-Level Security (RLS), it’s sometimes useful to run raw SQL in your tests as a sanity check on the Postgres policy itself, but your app-level suite should go through the ORM like everything else, as that is the path users actually hit.

The Permission Matrix

Instead of writing dozens of fragmented tests, we structure permission tests using a matrix. This matrix deliberately conflates AuthN (Authentication) and AuthZ (Authorization). We use a client_for(role) fixture to handle authentication, while the parametrize table asserts the authorization outcomes.

Also, it is critical to explicitly distinguish between HTTP 401 (you have no valid session) and HTTP 403 (we know who you are, but you are not allowed to do this). Conflating the two is a very common permission bug.

import pytest
from rest_framework import status

@pytest.mark.django_db
@pytest.mark.parametrize(
    "role, has_object_perm, expected_status",
    [
        ("admin", False, status.HTTP_200_OK),
        ("editor", True, status.HTTP_200_OK),
        ("editor", False, status.HTTP_403_FORBIDDEN),
        ("viewer", True, status.HTTP_200_OK),
        ("viewer", False, status.HTTP_403_FORBIDDEN),
        ("anonymous", False, status.HTTP_401_UNAUTHORIZED),
    ],
)
def test_document_retrieve_permissions(
    client_for, 
    document_factory, 
    role, 
    has_object_perm, 
    expected_status
):
    api_client, user = client_for(role)
    document = document_factory()
    
    if user and has_object_perm:
        from guardian.shortcuts import assign_perm
        assign_perm('view_document', user, document)

    response = api_client.get(f'/api/documents/{document.id}/')
    assert response.status_code == expected_status

3. The Mock Boundary: Where and What to Fake

In plain terms, mocking means swapping a real dependency—like a network call or file write—with a fake stand-in during a test so you can validate your logic without relying on external state.

The golden rule for the Mock Boundary is: Mock at the boundary where your code meets code you don’t own.

This applies equally to third-party vendors and internal microservices maintained by other teams. You can’t unilaterally change their contract, so you mock at that seam.

Does this risk missing real integration bugs? Yes, it shifts them; it doesn’t eliminate them. You should still keep a small number of real integration tests running against a vendor sandbox on a nightly basis to guard against “mock drift.” You can also use libraries like responses with recorded cassettes to periodically re-validate the shape of the mock.

Handling Celery and Non-Determinism

What about testing Celery tasks? The mock boundary rule applies here, too. Don’t mock your own task logic. Instead, mock the adapter it calls through, and set CELERY_TASK_ALWAYS_EAGER = True in your test settings so the task runs synchronously, allowing you to assert on the side effects directly.

If you are working with AI or LLMs, the adapter gets a second job: fixing the non-determinism at the seam. Use canned, recorded responses for your unit tests, and rely on a smaller set of properties (valid shape, expected fields) rather than exact output validation.


4. Common Anti-Patterns and Fixes

Through years of auditing Django codebases, I’ve seen the same anti-patterns repeatedly. Here’s how to fix them.

Anti-Pattern 1: The Leaky Test (Isolation Level Bugs)

The Problem: Callbacks (like on_commit hooks) silently never run during tests. The Fix: This is an isolation level issue. The standard TestCase wraps each test in a transaction that it eventually rolls back. Postgres only fires on_commit callbacks at actual commit. Since TestCase never commits, the callback never fires. Use TransactionTestCase (or @pytest.mark.django_db(transaction=True)) to force a real commit so you can test it.

Anti-Pattern 2: Over-Mocking

The Problem: You rename a local variable, and a test fails. The Fix: You are testing implementation details, not behavior. Treat your functions as black boxes. Assert that given Input X, they produce Output Y.

Anti-Pattern 3: The Fixture Graveyard

The Problem: A test loads a data.json fixture containing 500 records. Nobody knows which record the test actually relies on. The Fix: Delete your .json fixtures entirely. Use factory_boy to instantiate exactly and only the state required for the test.

Anti-Pattern 4: Test Ordering Dependencies

The Problem: Tests pass locally but fail intermittently on CI because they rely on shared state outside the database (caches, module-level dictionaries), which leaks across tests. The Fix: Install pytest-randomly. Order-dependent tests will fail the moment they are randomized, catching the leak immediately rather than six months later. (If you use pytest-xdist for parallel multi-tenant runs, isolation holds at the DB level automatically via DB cloning, so shared memory state is your primary culprit).

Anti-Pattern 5: The “God Factory”

The Problem: A factory that creates half the database every time it’s called. The Fix: Is the God Factory sometimes the right call for end-to-end integration tests? Yes. But it becomes an anti-pattern when it happens by accident as the default for a unit test that only needed a single field. Use lightweight base factories and apply specific traits when you need complex dependency trees.


The Ultimate Goal: Trust

Property-based testing advocates often point out that factories bake in blind spots because they give you realistic instances, not adversarial ones. That’s a fair point—use factories for “what production data actually looks like,” and tools like Hypothesis for “what’s the weirdest legal input.” You can also run mutation testing tools (like mutmut) periodically as a health check to validate that your tests actually catch bugs, rather than just providing cosmetic coverage.

Ultimately, the biggest win of adopting these TDD patterns isn’t necessarily raw speed. It is trust. When you eliminate flaky failures and brittle fixtures, developers stop re-running CI “just in case.”

Dive Deeper: A 3.5 Hour TDD Masterclass

If you want to see these concepts applied to a real codebase from scratch, I recorded a comprehensive 3.5-hour tutorial back at DjangoCon US 2023.

We walk through setting up pytest-django, configuring factories, and building robust, TDD-driven API endpoints step-by-step. Grab a coffee and check it out below:

If you’re at EuroPython, come say hi! I’d love to chat about how you’re scaling your Django applications.