Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 041 / 100

Testing Django apps with pytest: lessons from production

pytest-django makes Django tests pleasant.

Expona Admin7 min read

When we moved from Django's default test runner to pytest in production systems, test suites that took 12 minutes suddenly ran in 3. That difference compounds across hundreds of developers and thousands of test runs per week. Testing Django apps with pytest: lessons from production means adopting tools that make tests faster, clearer, and more maintainable without sacrificing coverage or reliability.

Why pytest matters for Django teams

Django's built-in test framework gets you started, but pytest-django transforms how we write and run tests at scale. We've deployed systems handling millions of transactions daily, and the teams maintaining them consistently choose pytest because it reduces cognitive load. You write less boilerplate, fixtures are composable, and the output is human-readable when things break at 3 AM.

The core advantage: pytest's fixture system replaces Django's class-based setup methods with simple functions. Instead of defining setUp and tearDown in every test class, you declare what each test needs and pytest wires it up. For teams running hundreds of tests across microservices, this clarity saves hours during debugging.

Getting started requires just two packages:

python
pip install pytest pytest-django

Then create a pytest.ini or add to pyproject.toml:

python
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings"
python_files = "tests.py test_*.py *_tests.py"
addopts = "-v --tb=short"

Now pytest discovers and runs your tests with cleaner output than Django's test runner.

Database access with @pytest.mark.django_db

In production systems processing real data, database isolation during testing is non-negotiable. Django's test framework wraps every test in a transaction that rolls back. pytest-django preserves this safety with a simple decorator.

When a test needs database access, mark it explicitly:

python
import pytest
from django.contrib.auth.models import User
from myapp.models import Order


@pytest.mark.django_db
def test_user_can_create_order():
    user = User.objects.create_user(username="alice", password="secure")
    order = Order.objects.create(user=user, total=99.99)
    assert order.user == user
    assert Order.objects.count() == 1

The decorator tells pytest to set up a test database, run the test in a transaction, and roll back afterward. Every test starts clean. In a ride-hailing system we supported, this isolation prevented race conditions that crashed integration tests only on Friday afternoons.

For read-only operations—queries that don't modify data—use the db fixture instead:

python
@pytest.mark.django_db(transaction=True)
def test_concurrent_order_processing():
    # Use transaction=True only when simulating concurrent access
    pass

Most tests need just @pytest.mark.django_db. The transaction=True parameter is rare; we've only needed it when testing signals or celery task isolation across multiple database connections.

One production lesson: always check your test database isn't being persisted. Set DATABASES in test settings to use in-memory SQLite or a separate test database. A team we worked with spent a day debugging why their test suite was affecting production data. The solution was one line in settings.

Building test data with factory_boy

Creating test objects by hand quickly becomes unmaintainable. After writing User.objects.create_user(...) 50 times, you want an abstraction. factory_boy provides exactly that—factories that generate realistic test data with sensible defaults.

Install it:

python
pip install factory-boy

Create a factories module:

python
# myapp/factories.py
import factory
from django.contrib.auth.models import User
from myapp.models import Order, OrderItem


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User
    
    username = factory.Sequence(lambda n: f"user_{n}")
    email = factory.Faker("email")
    is_active = True


class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order
    
    user = factory.SubFactory(UserFactory)
    total = factory.Faker("pydecimal", left_digits=5, right_digits=2, positive=True)
    status = "pending"


class OrderItemFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = OrderItem
    
    order = factory.SubFactory(OrderFactory)
    product_name = factory.Faker("word")
    quantity = factory.Faker("random_int", min=1, max=10)

Now tests become readable:

python
@pytest.mark.django_db
def test_order_total_calculation():
    order = OrderFactory(total=0)
    OrderItemFactory(order=order, quantity=2, product_name="Widget")
    OrderItemFactory(order=order, quantity=3, product_name="Gadget")
    
    order.refresh_from_db()
    assert order.items.count() == 2

In a SaaS system with multi-tenancy, factories saved us weeks when we refactored the data model. Every test still passed because we updated factory defaults once, rather than hunting through hundreds of test setup methods.

Use create() to save to the database, or build() to create an unsaved instance for testing validation without database overhead:

python
@pytest.mark.django_db
def test_order_validation():
    order = OrderFactory.build(total=-10)  # Not saved
    with pytest.raises(ValueError):
        order.full_clean()  # Django validation check

For complex test scenarios—like a trading system we maintained—use factory inheritance and overrides:

python
class PremiumOrderFactory(OrderFactory):
    class Meta:
        model = Order
    
    status = "priority"
    discount = 0.15

premium_order = PremiumOrderFactory()

Testing REST endpoints with APIClient

Django REST Framework (DRF) tests benefit enormously from pytest-django integration. The api_client fixture replaces Django's self.client, and tests read like documentation of API behavior.

Install pytest-django and create a conftest.py for shared fixtures:

python
# tests/conftest.py
import pytest
from rest_framework.test import APIClient


@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture
def authenticated_user(db):
    from django.contrib.auth.models import User
    user = User.objects.create_user(username="testuser", password="testpass")
    return user


@pytest.fixture
def authenticated_client(db, authenticated_user):
    client = APIClient()
    client.force_authenticate(user=authenticated_user)
    return client

Now test your endpoints:

python
@pytest.mark.django_db
def test_create_order_requires_authentication(api_client):
    response = api_client.post("/api/orders/", {"total": 50})
    assert response.status_code == 401


@pytest.mark.django_db
def test_authenticated_user_creates_order(authenticated_client):
    response = authenticated_client.post("/api/orders/", {
        "total": 50,
        "status": "pending"
    })
    assert response.status_code == 201
    assert response.data["total"] == "50.00"


@pytest.mark.django_db
def test_list_orders_pagination(authenticated_client, db):
    for _ in range(25):
        OrderFactory(user=authenticated_client.handler._force_user)
    
    response = authenticated_client.get("/api/orders/?limit=10")
    assert response.status_code == 200
    assert len(response.data["results"]) == 10
    assert response.data["count"] == 25

This approach has caught real bugs in production APIs. A team supporting an e-commerce platform discovered their pagination was off-by-one when they switched to this explicit testing style—the default Django tests had masked the issue.

Running tests in parallel with pytest-xdist

When your test suite reaches hundreds of tests, serial execution becomes a bottleneck. pytest-xdist distributes tests across CPU cores, turning a 5-minute test run into 1 minute on a 4-core machine.

Install it:

python
pip install pytest-xdist

Run with the -n flag:

python
pytest -n auto

The auto argument detects your CPU count. We've also used -n 8 in CI pipelines to explicitly parallelize across 8 workers.

One caution: parallel execution requires careful database isolation. pytest-django handles this, but tests must not share state. If a test modifies a global setting or cache without cleanup, parallel runs fail intermittently. Use fixtures to isolate state:

python
@pytest.fixture
def mock_external_service(monkeypatch):
    def mock_response(*args, **kwargs):
        return {"status": "success"}
    
    monkeypatch.setattr("myapp.services.external_api", mock_response)
    return mock_response


@pytest.mark.django_db
def test_service_integration(mock_external_service):
    result = myapp.services.call_external_api()
    assert result["status"] == "success"

In a travel booking system, switching to parallel tests reduced CI time from 18 minutes to 4 minutes. That compounds: a team pushing 10 times per day saves 140 minutes daily. Over a year, that's 500+ hours.

Common mistakes

Forgetting @pytest.mark.django_db: Tests that access the database without the decorator fail silently or with cryptic errors. Always mark database tests explicitly.

Using transaction=True everywhere: This disables the transaction rollback that isolates tests. It's slower and only needed for testing concurrent access or signals.

Creating fixtures without db parameter: If your fixture uses the database, it must either have db as a parameter or use @pytest.mark.django_db. Forgetting this causes "database is locked" errors in parallel runs.

Hardcoding test data: Tests that rely on specific IDs or timestamps break when run in different orders or environments. Factories generate fresh data for each test.

Not isolating external calls: Tests that hit real APIs are slow and unreliable. Mock them with monkeypatch or use responses library. We've seen test suites mysteriously fail when a third-party API went down.

Key takeaways

  • Use @pytest.mark.django_db to safely access the database; pytest-django rolls back after each test
  • factory_boy eliminates repetitive object creation and makes tests readable and maintainable
  • APIClient fixtures let you test REST endpoints with clear, composable test code
  • pytest-xdist parallelizes tests across CPU cores, cutting test time 4-8x on modern hardware
  • Explicit isolation via fixtures prevents state leaks that cause intermittent failures in parallel runs
  • Test readability matters: clear tests are easier to update when requirements change
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles