Django Testing: Pytest vs Unittest

python django testing

Django ships with unittest. But pytest has become the community favorite. Should you switch? When does each make sense? Let’s compare.

The Default: Django’s unittest

Django’s test framework builds on Python’s unittest:

from django.test import TestCase

class ArticleModelTest(TestCase):
    def setUp(self):
        self.article = Article.objects.create(
            title="Test Article",
            content="Test content"
        )
    
    def test_article_creation(self):
        self.assertEqual(self.article.title, "Test Article")
    
    def test_article_str(self):
        self.assertEqual(str(self.article), "Test Article")
    
    def tearDown(self):
        self.article.delete()

Run with:

python manage.py test

The Challenger: pytest

Same tests with pytest:

import pytest
from myapp.models import Article

@pytest.fixture
def article(db):
    return Article.objects.create(
        title="Test Article",
        content="Test content"
    )

def test_article_creation(article):
    assert article.title == "Test Article"

def test_article_str(article):
    assert str(article) == "Test Article"

Run with:

pytest

Key Differences

Assertions

# unittest
self.assertEqual(a, b)
self.assertIn(item, list)
self.assertTrue(condition)
self.assertRaises(Exception, func)

# pytest
assert a == b
assert item in list
assert condition
with pytest.raises(Exception):
    func()

pytest’s plain assertions are more readable and provide better failure messages.

Fixtures vs setUp/tearDown

# unittest: Class-based
class MyTest(TestCase):
    def setUp(self):
        self.user = User.objects.create_user('test')
    
    def tearDown(self):
        self.user.delete()

# pytest: Composable fixtures
@pytest.fixture
def user(db):
    return User.objects.create_user('test')

@pytest.fixture
def authenticated_client(client, user):
    client.login(username='test', password='password')
    return client

def test_dashboard(authenticated_client):
    response = authenticated_client.get('/dashboard/')
    assert response.status_code == 200

pytest fixtures are reusable, composable, and scoped.

Parametrization

# unittest: Verbose
class ValidationTest(TestCase):
    def test_valid_email_1(self):
        self.assertTrue(validate_email('test@example.com'))
    
    def test_valid_email_2(self):
        self.assertTrue(validate_email('user@domain.org'))

# pytest: Elegant parametrization
@pytest.mark.parametrize('email,expected', [
    ('test@example.com', True),
    ('user@domain.org', True),
    ('invalid', False),
    ('no@tld', False),
])
def test_email_validation(email, expected):
    assert validate_email(email) == expected

Test Discovery

# unittest (Django)
python manage.py test myapp.tests.test_models.ArticleTest.test_creation

# pytest
pytest myapp/tests/test_models.py::test_article_creation
pytest -k "article"  # Run tests matching pattern

Output

pytest provides better output by default:

Setting Up pytest-django

Installation

pip install pytest pytest-django

Configuration

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_test.py

Or in pyproject.toml:

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

Database Access

# Mark tests that need database
@pytest.mark.django_db
def test_user_creation():
    user = User.objects.create_user('test')
    assert User.objects.count() == 1

# Or use db fixture
def test_user_creation(db):
    user = User.objects.create_user('test')
    assert User.objects.count() == 1

pytest Fixtures for Django

Built-in Fixtures

def test_view(client):
    # Django test client
    response = client.get('/')
    assert response.status_code == 200

def test_admin(admin_client):
    # Logged-in admin client
    response = admin_client.get('/admin/')
    assert response.status_code == 200

def test_request(rf):
    # RequestFactory
    request = rf.get('/path/')
    response = my_view(request)
    assert response.status_code == 200

Custom Fixtures

# conftest.py
import pytest
from myapp.models import Article

@pytest.fixture
def article(db):
    return Article.objects.create(
        title="Test Article",
        author=None
    )

@pytest.fixture
def articles(db):
    return [
        Article.objects.create(title=f"Article {i}")
        for i in range(10)
    ]

@pytest.fixture
def api_client():
    from rest_framework.test import APIClient
    return APIClient()

@pytest.fixture
def authenticated_api_client(api_client, user):
    api_client.force_authenticate(user=user)
    return api_client

When to Use Each

Use unittest (Django default) When

Use pytest When

Migrating from unittest to pytest

Gradual Migration

pytest runs unittest tests! Start using pytest as runner:

# pytest runs both
pytest

Then migrate tests file by file.

Quick Conversions

# Before
class MyTest(TestCase):
    def test_something(self):
        self.assertEqual(1, 1)

# After
def test_something():
    assert 1 == 1

Keep TestCase When Needed

Some Django features require TestCase:

from django.test import TestCase

class DatabaseTest(TestCase):
    def test_query_count(self):
        with self.assertNumQueries(1):
            list(Article.objects.all())

Useful pytest Plugins

pip install pytest-cov        # Coverage
pip install pytest-xdist      # Parallel execution
pip install pytest-randomly   # Randomize test order
pip install pytest-mock       # Enhanced mocking
pip install pytest-factoryboy # Factory integration

Final Recommendation

For new Django projects: Use pytest.

The ecosystem, fixtures, and developer experience are significantly better. The migration path from unittest is smooth, and you can mix both approaches during transition.

# Start new project with pytest
pip install pytest pytest-django pytest-cov

Write tests that make you happy to run them.

All posts