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 pytestCreate 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 tracebacksThe 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 == 200rf (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 == 200admin_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 == 200settings
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.outboxdjango_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 == 401Parametrize 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_statuspytest-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 == 302Override 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 == 400Sharing 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 integrationRun in parallel with xdist:
pytest -n auto # Use all available CPUs
pytest -n 4 # Use 4 workersNote: 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-missingThe 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.