Django Intermediate #7: Testing — Django TestCase, fixtures, pytest-django
The last post of Django Intermediate is testing. From the built-in django.test.TestCase, through fixture/factory patterns, to the de facto standard pytest-django — all in one place.
With tests, you can refactor with confidence at places like the permission checks in #1 CBV, the query work in #2 ORM, or the side effects of #3 signals. This post is that confidence.
django.test.TestCase — basics
#
Django provides TestCase, layering DB and client integration on top of standard unittest.
from django.test import TestCase
from blog.models import Post
class PostModelTest(TestCase):
def test_str(self):
post = Post.objects.create(title="Hello", body="...")
self.assertEqual(str(post), "Hello")
def test_slug_auto(self):
post = Post.objects.create(title="Hello World", body="...")
self.assertEqual(post.slug, "hello-world")Key guarantees:
- Each test method is wrapped in a transaction and auto-rolled-back — one test’s data doesn’t leak into the next
- A separate test DB is created (
test_<original DB name>) — production data is safe - All standard unittest methods like
assertEqual,assertTrue,assertRaisesare usable
Running #
python manage.py test
python manage.py test blog # per app
python manage.py test blog.tests.test_models # per module
python manage.py test blog.tests.test_models.PostModelTest.test_slug_auto # per method
python manage.py test --keepdb # reuse DB (faster repeated runs)
python manage.py test --parallel # parallel run--keepdb cuts time significantly on repeated runs once migrations stabilize.
Client — HTTP integration testing
#
self.client, automatically present in TestCase, is a test HTTP client. It actually goes through URL conf to the view.
from django.test import TestCase
from django.urls import reverse
from blog.models import Post
class PostListViewTest(TestCase):
def test_get(self):
Post.objects.create(title="First post", body="...", published=True)
Post.objects.create(title="Second", body="...", published=True)
response = self.client.get(reverse("post_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "First post")
self.assertContains(response, "Second")
self.assertTemplateUsed(response, "blog/post_list.html")Common assertions:
| Assertion | Meaning |
|---|---|
assertEqual(response.status_code, 200) | Status code |
assertContains(response, text) | Text contained in body |
assertNotContains(response, text) | Not contained |
assertRedirects(response, url) | Redirect verification |
assertTemplateUsed(response, name) | Template used |
assertFormError(form, field, message) | Form error |
assertNumQueries(N) | Assert query count (regression guard for N+1 from #2) |
POST + form #
def test_create(self):
response = self.client.post(
reverse("post_create"),
data={"title": "New post", "body": "body"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(Post.objects.count(), 1)
post = Post.objects.first()
self.assertEqual(post.title, "New post")Login — client.login
#
from django.contrib.auth import get_user_model
class PostCreateViewTest(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="curtis", password="testpass"
)
def test_login_required(self):
response = self.client.get(reverse("post_create"))
self.assertEqual(response.status_code, 302) # to login page
def test_create_after_login(self):
self.client.login(username="curtis", password="testpass")
response = self.client.post(
reverse("post_create"),
data={"title": "post", "body": "..."},
)
self.assertRedirects(response, reverse("post_list"))client.login(...) or the faster client.force_login(user) (skips password verification).
Always use get_user_model() from #4 Users/permissions. Importing User directly breaks with custom user models.
assertNumQueries — N+1 regression guard
#
def test_list_efficient(self):
for i in range(10):
post = Post.objects.create(title=f"post{i}", body="...", published=True)
for j in range(3):
Comment.objects.create(post=post, body=f"comment{j}")
with self.assertNumQueries(2): # 1 for posts + 1 for comments prefetch
response = self.client.get(reverse("post_list"))
self.assertEqual(response.status_code, 200)If this test breaks, an N+1 regression has occurred. A powerful tool to guard against performance regressions with tests.
setUp vs setUpTestData
#
Two places to create test data.
class PostTest(TestCase):
def setUp(self):
self.author = User.objects.create_user(username="a", password="p")
self.post = Post.objects.create(title="t", body="b", author=self.author)class PostTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.author = User.objects.create_user(username="a", password="p")
cls.post = Post.objects.create(title="t", body="b", author=cls.author)setUp | setUpTestData | |
|---|---|---|
| Call frequency | Every test method | Once per class |
| Isolation | Strong | Isolated by savepoint (object itself shared) |
| Speed | Slow | Fast |
Default to setUpTestData — pay the object creation cost only once. Each test is isolated by a transaction savepoint. Caveat: setUpTestData puts objects on class attributes, so a test that mutates the object can affect other tests in the same class. Be careful.
Fixtures — bake data as JSON #
A long-standing pattern. Write seed data as JSON / YAML.
python manage.py dumpdata blog.Post --indent 2 > blog/fixtures/posts.json[
{
"model": "blog.post",
"pk": 1,
"fields": {"title": "Hello", "body": "...", "published": true}
}
]class PostTest(TestCase):
fixtures = ["posts.json"]
def test_loaded(self):
self.assertEqual(Post.objects.count(), 1)python manage.py loaddata posts.jsonPitfalls of fixtures #
- Data drifts away from code — JSON files have to be managed separately
- Fragile to migration changes — every fixture breaks when a field changes
- Hard to reuse — “I need something slightly different” is common, but you end up making a new file every time
Because of these pitfalls, real-world projects prefer the factory pattern.
Factory pattern — factory_boy
#
factory_boy is a pattern for creating model instances in code. It makes it easy to generate slightly different instances on demand.
pip install factory_boyimport factory
from django.contrib.auth import get_user_model
from blog.models import Post, Comment
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda u: f"{u.username}@example.com")
password = factory.PostGenerationMethodCall("set_password", "testpass")
class PostFactory(factory.django.DjangoModelFactory):
class Meta:
model = Post
title = factory.Sequence(lambda n: f"post {n}")
body = factory.Faker("paragraph")
author = factory.SubFactory(UserFactory)
published = True
class CommentFactory(factory.django.DjangoModelFactory):
class Meta:
model = Comment
post = factory.SubFactory(PostFactory)
body = factory.Faker("sentence")Usage #
def test_post_with_comments(self):
post = PostFactory() # all fields automatic
comments = CommentFactory.create_batch(5, post=post)
self.assertEqual(post.comments.count(), 5)
def test_unpublished(self):
post = PostFactory(published=False) # override only some
def test_with_specific_author(self):
author = UserFactory(username="curtis")
post = PostFactory(author=author)Key tools:
Sequence— incrementing 1, 2, 3, … per callFaker— fake data (names, sentences, emails, etc.)SubFactory— relation fields auto-generated by another factoryLazyAttribute— compute by referencing other fieldscreate_batch(N)— N at once
The drawbacks of fixtures almost all disappear. For new projects, the factory pattern is usually the answer.
pytest-django — de facto standard #
Django ecosystem testing is increasingly moving to pytest + pytest-django. Shorter, with stronger fixtures.
Setup #
pip install pytest pytest-django
# or
uv add --dev pytest pytest-django[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["tests.py", "test_*.py", "*_tests.py"]The shortest test #
import pytest
from blog.models import Post
@pytest.mark.django_db
def test_post_str():
post = Post.objects.create(title="Hello", body="...")
assert str(post) == "Hello"@pytest.mark.django_db marks this test needs DB access. pytest-django handles transactions + rollback automatically.
Built-in fixtures #
| Fixture | Meaning |
|---|---|
client | django.test.Client |
admin_client | Client logged in as superuser |
db | Enables DB access (usually replaced by the mark) |
transactional_db | Real transactions (needed for testing signals, etc.) |
settings | Temporarily change settings |
rf | RequestFactory |
import pytest
from django.urls import reverse
from blog.tests.factories import PostFactory
@pytest.mark.django_db
def test_list(client):
PostFactory.create_batch(3, published=True)
response = client.get(reverse("post_list"))
assert response.status_code == 200
assert b"" in response.content # response body existsCustom fixtures #
import pytest
from blog.tests.factories import UserFactory, PostFactory
@pytest.fixture
def user():
return UserFactory()
@pytest.fixture
def published_posts():
return PostFactory.create_batch(5, published=True)
@pytest.fixture
def auth_client(client, user):
client.force_login(user)
return client@pytest.mark.django_db
def test_my_posts(auth_client, user):
PostFactory.create_batch(3, author=user)
PostFactory.create_batch(2) # other authors
response = auth_client.get(reverse("my_posts"))
assert b"My posts" in response.contentPutting fixtures in conftest.py lets multiple test files share them automatically. It works like dependency injection.
Parametrize — same test with multiple inputs #
import pytest
@pytest.mark.parametrize("title,expected_slug", [
("Hello World", "hello-world"),
("Foo Bar Baz", "foo-bar-baz"),
(" spaces ", "spaces"),
])
@pytest.mark.django_db
def test_slug(title, expected_slug):
post = Post.objects.create(title=title, body="...")
assert post.slug == expected_slugThree cases in one function. On failure, it clearly shows which input broke.
Recommended directory structure #
blog/
├── models.py
├── views.py
├── ...
└── tests/
├── __init__.py
├── factories.py # factories
├── test_models.py
├── test_views.py
├── test_forms.py
└── test_signals.pyThe default single tests.py file is for small apps only. As an app grows, switch to a tests/ directory. Splitting by kind (test_models, test_views, …) makes things easy to find.
python manage.py test vs pytest
#
manage.py test | pytest | |
|---|---|---|
| Engine | Based on unittest | Based on pytest |
| Fixtures | setUp, setUpTestData | @pytest.fixture, conftest.py |
| Assertions | self.assertEqual(...) | assert one-liner |
| Parametrize | Manual (repeated methods) | @pytest.mark.parametrize |
| Plugin ecosystem | Narrow | Very wide |
| Learning curve | Low | A bit |
For new projects, pytest-django is usually the answer. If an existing project uses manage.py test, leave it as-is and add new tests in pytest — they’re compatible (pytest-django runs TestCase too).
Coverage — how much is tested #
pip install coveragecoverage run --source="." -m pytest
coverage report
coverage html # report at htmlcov/index.html[tool.coverage.run]
source = ["blog", "accounts"]
omit = [
"*/migrations/*",
"*/tests/*",
"manage.py",
"config/wsgi.py",
"config/asgi.py",
]
[tool.coverage.report]
fail_under = 80100% isn’t the goal. What matters more is whether important business logic, permission branches, edge cases are covered. Around 80% is usually the answer.
Test acceleration tips #
Slow tests slow development. Common acceleration tools:
--keepdb— save DB recreation cost--parallel(manage.py test) /pytest-xdist(pytest) — parallel execution- Use
setUpTestDataaggressively build()vscreate()in factory_boy — usebuild()(memory only) when DB save isn’t neededMIGRATION_MODULES = {"app": None}— skip migrations in tests (big effect)- Mock external calls — fake out network/external services with
unittest.mock
from unittest.mock import patch
@patch("blog.services.send_email")
def test_publish_sends_email(mock_send):
post = PostFactory()
post.publish()
mock_send.assert_called_once_with(post)Summary #
What we covered in this post:
django.test.TestCase— automatic transaction rollback, separate test DBClient— HTTP integration testing,assertContains/assertRedirects/assertTemplateUsedclient.login/client.force_login, useget_user_model()assertNumQueries— N+1 regression guardsetUp(each time) vssetUpTestData(once per class, fast)- Fixtures —
dumpdata/loaddata, JSON. Many pitfalls factory_boy—Sequence,Faker,SubFactory,create_batch. The answer for new projectspytest-django—@pytest.mark.django_db, built-in fixtures (client,settings,rf)- Share fixtures via
conftest.py, vary inputs via@pytest.mark.parametrize - Per-app
tests/directory + split by kind - Measure with
coverage, target around 80% - Acceleration:
--keepdb, parallel, mock, skip migrations
Series wrap-up #
That’s all of Django Intermediate, 7 posts.
| # | Post |
|---|---|
| 1 | CBV in depth |
| 2 | ORM intermediate |
| 3 | Signals and Middleware |
| 4 | Users/permissions |
| 5 | Messages/sessions/cookies |
| 6 | Static/Media operations |
| 7 | Testing ← this post |
Seven posts that turned the basic tools into proper tools. Next up — Django Advanced — async, optimization, caching, channels, security.
Starting with the next post (Django Advanced #1 Async views and ASGI), we’ll look at Django’s async model — async views, ASGI, the bridge between sync and async code, async ORM. One of the biggest changes in modern Django.