Django Intermediate #7: Testing — Django TestCase, fixtures, pytest-django

8 min read

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.

blog/tests/test_models.py
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, assertRaises are usable

Running #

run tests
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.

blog/tests/test_views.py
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:

AssertionMeaning
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 #

POST test
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 #

post-login test
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 #

asserting query count
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.

setUp — runs every test
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)
setUpTestData — once per class
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)
setUpsetUpTestData
Call frequencyEvery test methodOnce per class
IsolationStrongIsolated by savepoint (object itself shared)
SpeedSlowFast

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.

existing data into a fixture
python manage.py dumpdata blog.Post --indent 2 > blog/fixtures/posts.json
blog/fixtures/posts.json
[
  {
    "model": "blog.post",
    "pk": 1,
    "fields": {"title": "Hello", "body": "...", "published": true}
  }
]
loading in test
class PostTest(TestCase):
    fixtures = ["posts.json"]

    def test_loaded(self):
        self.assertEqual(Post.objects.count(), 1)
load into dev DB
python manage.py loaddata posts.json

Pitfalls 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.

install
pip install factory_boy
blog/tests/factories.py
import 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 #

in tests
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 call
  • Faker — fake data (names, sentences, emails, etc.)
  • SubFactory — relation fields auto-generated by another factory
  • LazyAttribute — compute by referencing other fields
  • create_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 #

install
pip install pytest pytest-django
# or
uv add --dev pytest pytest-django
pytest.ini (or pyproject.toml)
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
pyproject.toml form
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["tests.py", "test_*.py", "*_tests.py"]

The shortest test #

blog/tests/test_models.py
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 #

FixtureMeaning
clientdjango.test.Client
admin_clientClient logged in as superuser
dbEnables DB access (usually replaced by the mark)
transactional_dbReal transactions (needed for testing signals, etc.)
settingsTemporarily change settings
rfRequestFactory
using fixtures
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 exists

Custom fixtures #

conftest.py
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
usage
@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.content

Putting fixtures in conftest.py lets multiple test files share them automatically. It works like dependency injection.

Parametrize — same test with multiple inputs #

parametrize
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_slug

Three cases in one function. On failure, it clearly shows which input broke.

Recommended directory structure #

per-app tests directory
blog/
├── models.py
├── views.py
├── ...
└── tests/
    ├── __init__.py
    ├── factories.py        # factories
    ├── test_models.py
    ├── test_views.py
    ├── test_forms.py
    └── test_signals.py

The 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 testpytest
EngineBased on unittestBased on pytest
FixturessetUp, setUpTestData@pytest.fixture, conftest.py
Assertionsself.assertEqual(...)assert one-liner
ParametrizeManual (repeated methods)@pytest.mark.parametrize
Plugin ecosystemNarrowVery wide
Learning curveLowA 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 #

install
pip install coverage
run
coverage run --source="." -m pytest
coverage report
coverage html       # report at htmlcov/index.html
pyproject.toml — exclude config
[tool.coverage.run]
source = ["blog", "accounts"]
omit = [
    "*/migrations/*",
    "*/tests/*",
    "manage.py",
    "config/wsgi.py",
    "config/asgi.py",
]

[tool.coverage.report]
fail_under = 80

100% 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 setUpTestData aggressively
  • build() vs create() in factory_boy — use build() (memory only) when DB save isn’t needed
  • MIGRATION_MODULES = {"app": None} — skip migrations in tests (big effect)
  • Mock external calls — fake out network/external services with unittest.mock
mock example
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 DB
  • Client — HTTP integration testing, assertContains/assertRedirects/assertTemplateUsed
  • client.login / client.force_login, use get_user_model()
  • assertNumQueries — N+1 regression guard
  • setUp (each time) vs setUpTestData (once per class, fast)
  • Fixtures — dumpdata/loaddata, JSON. Many pitfalls
  • factory_boySequence, Faker, SubFactory, create_batch. The answer for new projects
  • pytest-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.

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.

X