Django Testing: Pytest vs Unittest
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:
- Colored output
- Progress bar
- Detailed failure diffs
- Short test summaries
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
- Simple project, few tests
- Team unfamiliar with pytest
- Following Django tutorials
- No complex fixture needs
Use pytest When
- Large test suite
- Need parametrized tests
- Want better output
- Complex fixture hierarchies
- Using pytest plugins
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:
- Transaction rollback behavior
assertNumQueries- Some client behaviors
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.