pytest-django Guide: pytest vs unittest for Django

pytest-django Guide: pytest vs unittest for Django

Django ships with unittest-based test tooling out of the box. pytest-django brings the full pytest ecosystem — fixtures, parametrize, plugins, and a cleaner API — to Django projects without rewriting your existing tests. This guide shows you how to set it up, what you gain, and when to reach for each approach.

Installation and Configuration

pip install pytest-django pytest

Create a pytest.ini or pyproject.toml section to tell pytest where Django lives:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*

Or in pyproject.toml:

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.test"
python_files = ["test_*.py", "*_test.py"]

Run tests:

pytest                          # All tests
pytest myapp/tests/             # Specific directory
pytest myapp/tests/test_views.py::TestProductViews::test_list
pytest -k "login"               # Tests matching keyword
pytest -v --tb=short            # Verbose with short tracebacks

The db Fixture and @pytest.mark.django_db

The most important pytest-django fixture is db. Without it, any test that touches the database raises a DatabaseBlockedByUser error. This forces you to be explicit about which tests need database access — a useful constraint.

import pytest
from myapp.models import Product

@pytest.mark.django_db
def test_create_product():
    product = Product.objects.create(name="Widget", price=9.99)
    assert product.pk is not None
    assert Product.objects.count() == 1

@pytest.mark.django_db
def test_product_str():
    product = Product.objects.create(name="Widget", price=9.99)
    assert str(product) == "Widget"

The django_db marker wraps each test in a transaction that rolls back after the test — equivalent to TestCase. For tests that need real transaction semantics (signals with on_commit), use transaction=True:

@pytest.mark.django_db(transaction=True)
def test_post_save_fires_on_commit():
    # Real commits, real on_commit callbacks
    with django.test.utils.CaptureQueriesContext(connection):
        Product.objects.create(name="Widget", price=9.99)

conftest.py

conftest.py is pytest's fixture definition file. Place it at the root of your test directory or your project root. pytest automatically loads it.

# conftest.py
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()

@pytest.fixture
def regular_user(db):
    return User.objects.create_user(
        username="alice",
        email="alice@example.com",
        password="correcthorse42!",
    )

@pytest.fixture
def admin_user(db):
    return User.objects.create_user(
        username="admin",
        email="admin@example.com",
        password="adminpass",
        is_staff=True,
        is_superuser=True,
    )

@pytest.fixture
def category(db):
    from myapp.models import Category
    return Category.objects.create(name="Electronics", slug="electronics")

@pytest.fixture
def product(db, category):
    from myapp.models import Product
    return Product.objects.create(
        name="Laptop",
        price=999.00,
        stock=10,
        category=category,
    )

Fixtures compose naturally — product depends on category, which depends on db. pytest resolves the dependency graph automatically.

Built-in Django Fixtures

pytest-django provides several ready-made fixtures:

client

The Django test client, pre-configured. No need to instantiate it.

@pytest.mark.django_db
def test_product_list(client):
    response = client.get("/products/")
    assert response.status_code == 200

@pytest.mark.django_db
def test_authenticated_view(client, regular_user):
    client.force_login(regular_user)
    response = client.get("/dashboard/")
    assert response.status_code == 200

rf (RequestFactory)

For testing views in isolation without URL routing:

from myapp.views import ProductListView

@pytest.mark.django_db
def test_product_list_view(rf, regular_user):
    request = rf.get("/products/")
    request.user = regular_user
    response = ProductListView.as_view()(request)
    assert response.status_code == 200

admin_client

A client pre-logged-in as a superuser:

@pytest.mark.django_db
def test_admin_can_access_admin(admin_client):
    response = admin_client.get("/admin/")
    assert response.status_code == 200

settings

Override settings within a test:

def test_email_backend(settings, client):
    settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
    # Now email goes to mail.outbox

django_db_setup

Hook for custom database setup, useful for read-only integration tests against a shared database.

parametrize

@pytest.mark.parametrize replaces multiple near-identical tests with one parametrized test. This is one of the biggest wins pytest has over unittest.

import pytest
from myapp.models import Product

@pytest.mark.django_db
@pytest.mark.parametrize("price,expected_valid", [
    (9.99, True),
    (0.00, True),
    (-1.00, False),
    (None, False),
    (999999.99, True),
])
def test_product_price_validation(price, expected_valid):
    if expected_valid:
        product = Product(name="Widget", price=price)
        product.full_clean()  # Should not raise
    else:
        with pytest.raises(Exception):
            product = Product(name="Widget", price=price)
            product.full_clean()

Parametrize across HTTP methods:

@pytest.mark.django_db
@pytest.mark.parametrize("method", ["patch", "put", "delete"])
def test_write_methods_require_auth(client, product, method):
    url = f"/api/products/{product.pk}/"
    response = getattr(client, method)(url, content_type="application/json")
    assert response.status_code == 401

Parametrize with IDs for readable output:

@pytest.mark.parametrize("role,expected_status", [
    pytest.param("regular", 403, id="regular-user-forbidden"),
    pytest.param("staff", 200, id="staff-user-allowed"),
    pytest.param("superuser", 200, id="superuser-allowed"),
])
@pytest.mark.django_db
def test_admin_access_by_role(client, role, expected_status):
    user = User.objects.create_user(
        username=f"user_{role}",
        password="x",
        is_staff=(role in ("staff", "superuser")),
        is_superuser=(role == "superuser"),
    )
    client.force_login(user)
    response = client.get("/admin/")
    assert response.status_code == expected_status

pytest-factoryboy

pytest-factoryboy bridges factory_boy and pytest fixtures. Register factories and get fixtures for free.

pip install pytest-factoryboy
# conftest.py
from pytest_factoryboy import register
from tests.factories import UserFactory, CategoryFactory, ProductFactory

register(UserFactory)
register(CategoryFactory)
register(ProductFactory)

Now every registered factory generates a snake_case fixture automatically:

@pytest.mark.django_db
def test_product_belongs_to_category(product, category):
    # `product` and `category` fixtures created by pytest-factoryboy
    assert product.category == category

@pytest.mark.django_db
def test_user_can_order_product(user, product, client):
    client.force_login(user)
    response = client.post(f"/cart/add/{product.pk}/", {"quantity": 1})
    assert response.status_code == 302

Override factory fields in individual tests using the _request fixture pattern:

@pytest.mark.django_db
def test_out_of_stock_product(product_factory):
    oos = product_factory(stock=0, is_active=False)
    response = client.post(f"/cart/add/{oos.pk}/", {"quantity": 1})
    assert response.status_code == 400

Sharing Fixtures with scope

Fixture scope controls how often a fixture is created. function (default) creates a new instance per test. class, module, and session share instances.

@pytest.fixture(scope="module")
def expensive_setup():
    # Created once per module — much faster for read-heavy setups
    return SomeExpensiveObject()

Warning: database fixtures scoped above function require @pytest.mark.django_db(databases=["default"]) and can't rely on transaction rollback. Use django_db_modify_db_settings or --reuse-db patterns carefully.

For most Django tests, stick with function scope and rely on transaction rollback for isolation.

pytest vs unittest: When to Use Each

Concern pytest unittest
Test syntax def test_*, plain assert def test_*, self.assert* methods
Fixtures Composable, injected setUp/tearDown per class
Parametrize @pytest.mark.parametrize Requires subTest or repetition
Plugins Rich ecosystem Limited
Class-based Optional Required
Django integration pytest-django Built-in
Existing TestCase Works unchanged Native

Practical recommendation: use pytest as the runner even if your tests are still TestCase subclasses. pytest runs Django's TestCase tests without changes. Migrate to function-based tests and pytest fixtures incrementally, starting with new test files.

Useful pytest Plugins for Django

pip install pytest-cov          # Coverage reports
pip install pytest-xdist        # Parallel test execution
pip install pytest-randomly     # Randomize test order
pip install pytest-timeout      # Fail tests that hang
pip install pytest-django       # Core Django integration
pip install pytest-factoryboy   # factory_boy integration

Run in parallel with xdist:

pytest -n auto                  # Use all available CPUs
pytest -n 4                     # Use 4 workers

Note: parallel execution requires tests to be truly independent. Any shared state — global variables, singleton patterns, static class attributes — will cause flaky failures under parallelism.

For coverage:

pytest --cov=myapp --cov-report=html --cov-report=term-missing

The switch from manage.py test to pytest-django is low friction and pays back quickly in faster iteration and better failure output. For continuous verification against deployed environments, HelpMeTest runs end-to-end browser checks that complement your pytest suite — catching regressions that only appear in production configuration.

Read more

Start now free