Recap

We have now covered the full width of the Django testing landscape. Article 1 established the mental model: the test pyramid, types of tests, and what makes tests worth having. Articles 2 and 3 built and migrated a unit test suite. Article 4 moved up to integration tests: views, APIs, serializers, URL routing, and middleware. Article 5 covered every auth and permission boundary. Article 6 built a complete feature from scratch using TDD, red-green-refactor, one cycle at a time.

Across all six articles, one pattern has appeared constantly: test setup. Creating users, customers, products, orders, coupons, and reviews before every test. We used objects.create() directly, and it worked. But as a test suite grows to hundreds of tests, that approach develops cracks.

This article fixes those cracks. We introduce factory_boy, the standard library for test data in Python, go deep on pytest fixture scoping, and establish the discipline of hermetic test isolation: tests that never depend on each other, never share mutable state, and never fail for reasons unrelated to the code they test.

The problem with manual setup

Consider what happens when you create test data manually across a growing suite:

python
# This appears in 30 different test files
user = User.objects.create_user(username="alice", password="pass")
customer = Customer.objects.create(name="Alice", email="alice@example.com", user=user)
product = Product.objects.create(name="Widget", price=Decimal("25.00"), stock=10)
order = Order.objects.create(customer=customer, status="pending")

Now the Customer model gains a required phone field. Every one of those 30 test files breaks. You spend an hour fixing setup code that has nothing to do with the logic being tested.

Or the User model enforces a minimum password length. Every test that creates a user with password="pass" silently fails validation, and the failures cascade in confusing ways.

Manual setup also produces data that is coupled to the test that creates it. When two tests need slightly different data, each duplicates the full setup with one field changed. The duplication spreads, the setup grows longer than the assertions, and reading a test file becomes archaeology.

There are three problems: fragility (model changes break unrelated tests), duplication (the same setup repeated everywhere), and noise (setup drowns out the logic being tested).

factory_boy solves all three.

Introducing factory_boy

factory_boy is a Python library for creating test objects. You define a factory once for each model, specifying sensible defaults for every field. Tests create objects through the factory, overriding only the fields that matter for that specific test.

When the model changes, you update the factory. The change propagates to every test automatically, because tests do not hard-code field values they do not care about.

bash
pip install factory-boy

Basic factories

A factory is a class that extends factory.django.DjangoModelFactory. The Meta inner class points to the model. Each field on the factory is a default value.

orders/factories.py
import factory
from factory.django import DjangoModelFactory
from django.contrib.auth import get_user_model
from decimal import Decimal
from .models import Customer, Product, Order, Coupon
from django.utils import timezone
from datetime import timedelta

User = get_user_model()


class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f"user{n}")
    email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
    password = factory.PostGenerationMethodCall("set_password", "testpass123")


class CustomerFactory(DjangoModelFactory):
    class Meta:
        model = Customer

    user = factory.SubFactory(UserFactory)
    name = factory.LazyAttribute(lambda obj: obj.user.username.capitalize())
    email = factory.LazyAttribute(lambda obj: obj.user.email)
    is_loyalty_member = False


class ProductFactory(DjangoModelFactory):
    class Meta:
        model = Product

    name = factory.Sequence(lambda n: f"Product {n}")
    price = Decimal("25.00")
    stock = 10


class CouponFactory(DjangoModelFactory):
    class Meta:
        model = Coupon

    code = factory.Sequence(lambda n: f"COUPON{n}")
    discount_percent = 10
    expires_at = factory.LazyFunction(lambda: timezone.now() + timedelta(days=7))


class OrderFactory(DjangoModelFactory):
    class Meta:
        model = Order

    customer = factory.SubFactory(CustomerFactory)
    status = "pending"
    subtotal = Decimal("0.00")

With these factories, creating test objects becomes one line:

python
# Creates a User, Customer, and Order in one call
order = OrderFactory()

# Creates a Product with specific price
product = ProductFactory(price=Decimal("50.00"))

# Creates a confirmed order
confirmed_order = OrderFactory(status="confirmed")

# Creates an object in memory without saving (useful for unit tests)
product = ProductFactory.build(name="Widget")

create() saves to the database. build() creates an unsaved instance. build_batch(n) and create_batch(n) produce a list of n objects.

Sequences

A sequence is a counter that increments each time a factory creates an object. Sequences solve the most common problem with test data: unique constraint violations when creating multiple objects of the same type.

python
class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    # factory.Sequence passes an auto-incrementing integer n to the lambda
    username = factory.Sequence(lambda n: f"user{n}")
    email = factory.Sequence(lambda n: f"user{n}@example.com")
python
user_a = UserFactory()  # username="user0", email="user0@example.com"
user_b = UserFactory()  # username="user1", email="user1@example.com"
user_c = UserFactory()  # username="user2", email="user2@example.com"

Without sequences, creating multiple users would fail because username and email are unique. With sequences, every object gets a unique value automatically, and you never think about it again.

Note

Sequences reset between test runs but not between tests

The sequence counter starts at 0 when the test process starts and increments globally across all tests in that run. Tests should never assert on the exact value of a sequenced field (like asserting username == 'user0') because the counter value depends on test execution order. Assert on structure, not on generated values.

Lazy attributes

A LazyAttribute is computed when the object is created, not when the factory class is defined. This lets you derive one field from another.

python
class CustomerFactory(DjangoModelFactory):
    class Meta:
        model = Customer

    user = factory.SubFactory(UserFactory)

    # name is derived from user.username at creation time
    name = factory.LazyAttribute(lambda obj: obj.user.username.capitalize())

    # email mirrors the user's email
    email = factory.LazyAttribute(lambda obj: obj.user.email)

LazyFunction is similar but does not receive the object under construction. Use it for values that depend on external state (like the current time) rather than other fields on the object.

python
import factory
from django.utils import timezone
from datetime import timedelta


class CouponFactory(DjangoModelFactory):
    class Meta:
        model = Coupon

    # LazyFunction: computed fresh each time, no access to obj
    expires_at = factory.LazyFunction(lambda: timezone.now() + timedelta(days=7))

✦ Tip

Use LazyFunction for time-dependent defaults

If you used a plain attribute (expires_at = timezone.now() + timedelta(days=7)), the timestamp would be evaluated once when the class is defined, not when each object is created. LazyFunction ensures a fresh timestamp on every factory call.

The most powerful feature of factory_boy is how it handles related models. A SubFactory creates a related object automatically when the parent is created. The relationship is built up the graph: creating an Order automatically creates a Customer, which automatically creates a User.

python
# Creates User -> Customer -> Order in one call
order = OrderFactory()

print(order.customer.name)       # "User0" (from CustomerFactory default)
print(order.customer.user.email) # "user0@example.com" (from UserFactory default)

# Override the customer
specific_customer = CustomerFactory(name="Alice")
order = OrderFactory(customer=specific_customer)

# Override a field on the subfactory using double underscore notation
order = OrderFactory(customer__name="Bob")
print(order.customer.name)  # "Bob"

The double-underscore notation (customer__name="Bob") is one of factory_boy's most useful features. It lets you reach into a nested subfactory and override a specific field without creating the customer manually. The factory creates everything, wires it together, and applies your override.

python
# Create a loyalty member's order in one line
order = OrderFactory(customer__is_loyalty_member=True)

# Create an order with a specific product price (if products are related)
# You can go arbitrarily deep
order = OrderFactory(customer__user__email="specific@example.com")

Traits

Traits are named presets that flip a group of fields at once. Instead of repeating a set of overrides in every test that needs a "loyalty member" or an "expired coupon", you define the preset once in the factory and activate it by name.

python
import factory
from factory.django import DjangoModelFactory
from django.utils import timezone
from datetime import timedelta
from .models import Customer, Coupon, Order


class CustomerFactory(DjangoModelFactory):
    class Meta:
        model = Customer

    user = factory.SubFactory(UserFactory)
    name = factory.LazyAttribute(lambda obj: obj.user.username.capitalize())
    email = factory.LazyAttribute(lambda obj: obj.user.email)
    is_loyalty_member = False

    class Params:
        loyalty = factory.Trait(is_loyalty_member=True)


class CouponFactory(DjangoModelFactory):
    class Meta:
        model = Coupon

    code = factory.Sequence(lambda n: f"COUPON{n}")
    discount_percent = 10
    expires_at = factory.LazyFunction(lambda: timezone.now() + timedelta(days=7))

    class Params:
        expired = factory.Trait(
            expires_at=factory.LazyFunction(
                lambda: timezone.now() - timedelta(seconds=1)
            )
        )
        high_value = factory.Trait(discount_percent=50)


class OrderFactory(DjangoModelFactory):
    class Meta:
        model = Order

    customer = factory.SubFactory(CustomerFactory)
    status = "pending"
    subtotal = factory.LazyFunction(lambda: __import__("decimal").Decimal("0.00"))

    class Params:
        confirmed = factory.Trait(status="confirmed")
        shipped = factory.Trait(status="shipped")
        cancelled = factory.Trait(status="cancelled")

Traits make test setup self-documenting. The code reads like the scenario it represents:

python
# Without traits — noise
customer = CustomerFactory(is_loyalty_member=True)
coupon = CouponFactory(expires_at=timezone.now() - timedelta(seconds=1))
order = OrderFactory(status="shipped")

# With traits — signal
customer = CustomerFactory(loyalty=True)
coupon = CouponFactory(expired=True)
order = OrderFactory(shipped=True)

# Traits compose
coupon = CouponFactory(expired=True, high_value=True)

SubFactories and deep graphs

Some features require deep object graphs: an order that belongs to a loyalty member, reviewed by a specific user, with a coupon that has not expired. Building these graphs manually takes ten lines of setup. With subfactories and traits, it takes one or two.

reviews/factories.py
import factory
from factory.django import DjangoModelFactory
from orders.factories import ProductFactory, UserFactory
from .models import Review


class ReviewFactory(DjangoModelFactory):
    class Meta:
        model = Review

    user = factory.SubFactory(UserFactory)
    product = factory.SubFactory(ProductFactory)
    rating = 4
    body = ""

    class Params:
        positive = factory.Trait(rating=5, body="Excellent product.")
        negative = factory.Trait(rating=1, body="Very disappointed.")
python
# A realistic test scenario in two lines of setup
loyalty_order = OrderFactory(customer__loyalty=True, shipped=True)
positive_review = ReviewFactory(user=loyalty_order.customer.user, positive=True)

This is the payoff. Test setup that used to be 8 lines of objects.create() calls with careful ordering of related objects is now 2 lines. The factory handles the graph. The test handles the assertion.

Using factories in pytest

Factories do not replace fixtures. They complement them. The best pattern is to define a small set of fixtures that use factories for the common cases, and call factories directly inside tests for scenarios that are specific to that test.

conftest.py
import pytest
from orders.factories import UserFactory, CustomerFactory, ProductFactory, OrderFactory, CouponFactory
from reviews.factories import ReviewFactory


@pytest.fixture
def user(db):
    return UserFactory()


@pytest.fixture
def other_user(db):
    return UserFactory()


@pytest.fixture
def customer(db, user):
    return CustomerFactory(user=user)


@pytest.fixture
def loyalty_customer(db):
    return CustomerFactory(loyalty=True)


@pytest.fixture
def product(db):
    return ProductFactory()


@pytest.fixture
def order(db, customer):
    return OrderFactory(customer=customer)


@pytest.fixture
def valid_coupon(db):
    return CouponFactory()


@pytest.fixture
def expired_coupon(db):
    return CouponFactory(expired=True)

For tests that need many objects of the same type (pagination tests, aggregation tests, filter tests), call create_batch directly inside the test body rather than in a fixture. Fixtures should provide the objects that most tests in a file need. Bulk data is test-specific.

python
@pytest.mark.django_db
def test_pagination_returns_ten_results_per_page(authenticated_api_client, customer):
    # Direct factory call inside the test — this data is specific to this test
    OrderFactory.create_batch(15, customer=customer)

    response = authenticated_api_client.get(reverse("api:order-list"))

    assert response.data["count"] == 15
    assert len(response.data["results"]) == 10


@pytest.mark.django_db
def test_average_rating_across_many_reviews(product):
    from reviews.factories import ReviewFactory

    ReviewFactory.create_batch(5, product=product, rating=4)
    ReviewFactory.create_batch(5, product=product, rating=2)

    assert product.average_rating == 3.0

Fixture scoping strategies

pytest fixtures run at a configurable scope: function, class, module, or session. The scope determines how often the fixture is created and how long it lives. Choosing the right scope is one of the most important decisions for keeping a test suite both fast and reliable.

Function scope (the default)

The fixture runs before each test function and is discarded after. This is the safest scope: each test gets a completely fresh object with no contamination from previous tests.

Use function scope for any fixture that creates mutable state, especially database objects. Django's TestCase transaction wrapping and pytest-django's db fixture both operate at function scope, rolling back after each test.

python
@pytest.fixture(scope="function")  # default, usually omitted
def order(db, customer):
    return OrderFactory(customer=customer)

Class scope

The fixture runs once per test class and is shared across all methods in that class. Useful when you are grouping related tests that all need the same object and none of them mutate it.

python
@pytest.fixture(scope="class")
def product_with_reviews(django_db_setup, db):
    product = ProductFactory()
    ReviewFactory.create_batch(5, product=product, rating=4)
    return product


class TestProductRating:
    def test_average_is_correct(self, product_with_reviews):
        assert product_with_reviews.average_rating == 4.0

    def test_review_count_is_correct(self, product_with_reviews):
        assert product_with_reviews.reviews.count() == 5

Module scope

The fixture runs once per test module (file). Use this for expensive read-only setup that all tests in a file need, such as a large set of seed data used by many independent tests. The same cautions about mutation apply: a test that modifies the shared object will corrupt subsequent tests.

python
@pytest.fixture(scope="module")
def product_catalog(django_db_setup, db):
    return ProductFactory.create_batch(50)

Session scope

The fixture runs once for the entire test session. The most common use case is django_db_setup itself, which creates the test database once. Avoid session-scoped fixtures for anything your tests might modify. The contamination risk is too high.

⚠ Gotcha

Wider scope requires explicit database access

Function-scoped fixtures get database access automatically through the db or django_db_setup fixture. Class, module, and session-scoped fixtures must explicitly declare django_db_setup as a dependency, because the default db fixture only covers function scope.

python
# Wrong: class-scoped fixture without explicit db access
@pytest.fixture(scope="class")
def shared_product():
    return ProductFactory()  # RuntimeError: database access not allowed


# Correct
@pytest.fixture(scope="class")
def shared_product(django_db_setup, db):
    return ProductFactory()

Shared state bugs

A shared state bug is when one test modifies an object that another test expects to be in its original state. These bugs are intermittent (they depend on test execution order), hard to diagnose (the failing test looks correct in isolation), and silently introduced by widening fixture scope.

A shared state bug
# shared_order is class-scoped — created once for the whole class
@pytest.fixture(scope="class")
def shared_order(django_db_setup, db):
    return OrderFactory(status="pending")


class TestOrderCancellation:
    def test_cancel_pending_order(self, shared_order):
        # This test mutates shared_order
        cancel_order(shared_order)
        shared_order.refresh_from_db()
        assert shared_order.status == "cancelled"  # passes

    def test_pending_order_is_cancellable(self, shared_order):
        # Now shared_order.status is "cancelled" — this test fails for the wrong reason
        assert shared_order.is_cancellable  # FAILS

The fix is straightforward: use function scope so each test gets its own fresh object, or never mutate the shared object (fetch a fresh copy from the database instead).

Fixed with function scope
@pytest.fixture  # function scope (default)
def order(db):
    return OrderFactory(status="pending")


def test_cancel_pending_order(order):
    cancel_order(order)
    order.refresh_from_db()
    assert order.status == "cancelled"


def test_pending_order_is_cancellable(order):
    # Fresh order for this test — status is "pending" as expected
    assert order.is_cancellable

If you genuinely need class scope for performance reasons, fetch a fresh database copy inside the test instead of using the shared object directly:

python
class TestOrderCancellation:
    def test_cancel_pending_order(self, shared_order):
        # Fetch a fresh copy rather than mutating the shared fixture
        order = Order.objects.get(pk=shared_order.pk)
        cancel_order(order)
        order.refresh_from_db()
        assert order.status == "cancelled"

    def test_pending_order_is_cancellable(self, shared_order):
        # shared_order is untouched — safe to read
        assert shared_order.is_cancellable

Database isolation

Django's TestCase and pytest-django's db fixture both wrap each test in a transaction that is rolled back after the test completes. This means database writes in one test are never visible to another. This isolation is automatic and requires no cleanup code in your tests.

However, there are two situations where this isolation breaks down.

TransactionTestCase

Some tests require real transactions: testing atomic() blocks, testing behaviour that depends on the on_commit hook, or testing code that uses multiple database connections. For these, use Django's TransactionTestCase or pytest-django's django_db(transaction=True) mark.

TransactionTestCase does not use transaction rollback for cleanup. It truncates all tables after each test, which is significantly slower. Use it only when you specifically need transaction semantics.

python
@pytest.mark.django_db(transaction=True)
def test_on_commit_hook_fires_after_transaction(order):
    with mock.patch("orders.tasks.send_confirmation_email") as mock_send:
        with transaction.atomic():
            order.status = "confirmed"
            order.save()
        # on_commit hooks only fire when the outer transaction commits
        mock_send.assert_called_once()

setUpTestData and class-level isolation

Django's setUpTestData creates objects once for the entire test class inside a savepoint, then restores them between tests. Tests that try to modify these objects will see their changes rolled back before the next test runs, so the shared objects are always in their original state.

In pytest terms, the equivalent is a class-scoped fixture that wraps the setup in TestCase.setUpTestData semantics. The simplest approach in pytest is to use a function-scoped fixture with factory_boy, which is fast enough for most suites.

Avoiding the database in unit tests

The fastest test is one that never touches the database. Every test that creates a database record spends time on I/O: the insert, the transaction overhead, and the rollback. For unit tests that only need to call a method on a model, use factory.build() or construct the object directly with Model().

python
# Slow: creates a database record just to test a method
@pytest.mark.django_db
def test_order_total_applies_tax(db):
    order = OrderFactory(subtotal=Decimal("100.00"), tax_rate=Decimal("0.16"))
    assert order.total == Decimal("116.00")


# Fast: no database involved
def test_order_total_applies_tax():
    order = OrderFactory.build(subtotal=Decimal("100.00"), tax_rate=Decimal("0.16"))
    assert order.total == Decimal("116.00")


# Even faster: plain constructor, no factory overhead
def test_order_total_applies_tax():
    from decimal import Decimal
    from orders.models import Order
    order = Order(subtotal=Decimal("100.00"), tax_rate=Decimal("0.16"))
    assert order.total == Decimal("116.00")

The rule is simple: if a test does not assert on database state, it should not touch the database. A model property test, a method test, a utility function test, and a permission class test all fall into this category.

When you use factory.build(), factory_boy calls SubFactory references with build() automatically. The entire object graph is built in memory without a single database call.

python
# All SubFactories are also built (not saved) — zero database calls
order = OrderFactory.build(subtotal=Decimal("100.00"))
print(order.customer)        # Customer instance in memory
print(order.customer.user)   # User instance in memory
print(order.pk)              # None — not saved

Test doubles and mocking

Some dependencies cannot be controlled through factories: external HTTP APIs, email services, third-party payment gateways, Celery tasks, and the filesystem. For these, use test doubles: objects that stand in for the real dependency and return controlled responses.

The standard library provides unittest.mock. The two tools you will use most are Mock (a generic double) and patch (which replaces a name in a module temporarily).

python
from unittest.mock import patch, MagicMock


@pytest.mark.django_db
def test_order_confirmation_email_is_sent_on_create(authenticated_api_client, customer):
    with patch("orders.tasks.send_confirmation_email") as mock_send:
        authenticated_api_client.post(
            reverse("api:order-list"),
            data={"subtotal": "100.00"},
            format="json",
        )
        mock_send.assert_called_once()


@pytest.mark.django_db
def test_payment_failure_returns_402(authenticated_api_client, order):
    with patch("orders.services.payment_gateway.charge") as mock_charge:
        mock_charge.side_effect = Exception("Card declined")

        response = authenticated_api_client.post(
            reverse("api:charge-order", kwargs={"pk": order.pk})
        )
        assert response.status_code == 402

The scope of a patch context manager is exactly one test. The real function is restored when the with block exits. This is hermetic: the mock never leaks into another test.

If you use pytest-mock (covered in Article 3), the mocker fixture achieves the same result with less nesting:

python
@pytest.mark.django_db
def test_order_confirmation_email_is_sent_on_create(
    authenticated_api_client, customer, mocker
):
    mock_send = mocker.patch("orders.tasks.send_confirmation_email")

    authenticated_api_client.post(
        reverse("api:order-list"),
        data={"subtotal": "100.00"},
        format="json",
    )

    mock_send.assert_called_once()

✦ Tip

Patch at the point of use, not the point of definition

If orders.views imports send_confirmation_email from orders.tasks, patch orders.views.send_confirmation_email, not orders.tasks.send_confirmation_email. The view already holds a reference to the original function. Patching the source module after the import has no effect on the reference the view holds.

python
# Wrong: patching the definition after the view has already imported it
mocker.patch("orders.tasks.send_confirmation_email")

# Correct: patching the name as the view sees it
mocker.patch("orders.views.send_confirmation_email")

Summary

  • Manual objects.create() setup in every test produces three problems: fragility (model changes break unrelated tests), duplication (the same setup repeated everywhere), and noise (setup drowns out the assertions).
  • factory_boy solves all three. Define defaults once per model, override only what matters per test, and let the factory manage the object graph.
  • Use factory.Sequence for fields with unique constraints. The counter increments automatically and you never hit a uniqueness error.
  • Use factory.LazyAttribute to derive one field from another at creation time. Use factory.LazyFunction for values that depend on external state like the current time.
  • SubFactory builds related objects automatically. Override nested fields with double-underscore notation: OrderFactory(customer__loyalty=True).
  • Traits are named presets that group related field overrides. They make test setup self-documenting: CouponFactory(expired=True) is clearer than repeating the expiry timestamp calculation everywhere.
  • Use function scope (the default) for database fixtures. Widen scope only when there is a measurable performance reason and only for objects that tests will not mutate.
  • A shared state bug is when one test mutates a shared fixture and corrupts subsequent tests. The symptom is a test that passes in isolation but fails in the full suite. The fix is function scope or fetching a fresh database copy inside the test.
  • Use factory.build() or plain Model() constructors for unit tests that do not assert on database state. Zero I/O means maximum speed.
  • Patch at the point of use, not the point of definition. A name imported by a module must be patched on that module, not on the source module it came from.

In the final article we pull everything together: measuring and interpreting coverage, running the suite in GitHub Actions CI, making the suite fast enough that nobody skips it, and knowing when the suite is good enough to ship on.