Testing Django apps

Write fast tests with TestCase, drive views through the test client, and check queries, redirects and form errors instead of just asserting 200.

TestCase and the test database

# blog/tests/test_models.py
from django.test import TestCase
from django.utils import timezone
from blog.models import Category, Post

class PostModelTests(TestCase):
    @classmethod
    def setUpTestData(cls):                 # runs once, wrapped in a class transaction
        cls.author = get_user_model().objects.create_user("ana", password="pw12345678")
        cls.news = Category.objects.create(name="News", slug="news")

    def setUp(self):                        # runs before every test method
        self.post = Post.objects.create(title="Hello", slug="hello", author=self.author)

    def test_publish_sets_status_and_timestamp(self):
        self.post.publish()
        self.post.refresh_from_db()
        self.assertEqual(self.post.status, Post.Status.PUBLISHED)
        self.assertIsNotNone(self.post.published_at)

    def test_draft_is_not_published(self):
        self.assertEqual(self.post.status, Post.Status.DRAFT)
  • Django creates a test database (test_<name>) on the first run, applies migrations, and destroys it at the end. Your development data is never touched.
  • TestCase wraps each test in a transaction and rolls it back, so tests are isolated without deleting rows. TransactionTestCase is needed only when you test transaction behaviour itself, and it is much slower.
  • setUpTestData builds shared objects once per class; setUp builds per-test data. Prefer setUpTestData for fixtures nobody mutates — it is the difference between a two-second and a twenty-second suite.
  • Files must be named test*.py (or live in a tests/ package) for the runner to discover them. Run a subset with python manage.py test blog.tests.test_models.PostModelTests.test_publish_sets_status_and_timestamp.

Testing views with the client

# blog/tests/test_views.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from blog.models import Post

class PostViewTests(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user("ana", password="pw12345678")
        self.post = Post.objects.create(title="Hello", slug="hello",
                                        author=self.user, status="published")

    def test_list_shows_published_post(self):
        response = self.client.get(reverse("blog:list"))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Hello")
        self.assertTemplateUsed(response, "blog/post_list.html")

    def test_draft_returns_404_for_anonymous(self):
        self.post.status = "draft"
        self.post.save(update_fields=["status"])
        self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404)

    def test_login_redirects_to_next(self):
        response = self.client.get(reverse("blog:drafts"))
        self.assertRedirects(response, f"{reverse('login')}?next={reverse('blog:drafts')}")

    def test_create_requires_auth_and_saves_author(self):
        self.client.force_login(self.user)          # bypasses the login form
        response = self.client.post(reverse("blog:create"),
                                    {"title": "New", "slug": "new", "body": "x" * 60})
        self.assertEqual(response.status_code, 302)
        self.assertEqual(Post.objects.get(slug="new").author, self.user)

    def test_query_count_is_stable(self):
        with self.assertNumQueries(2):              # 1 posts + 1 authors
            self.client.get(reverse("blog:list"))
AssertionChecks
assertContains(response, text)Body contains text (and respects status) with status_code=
assertRedirects(response, url)3xx target, following the chain by default
assertTemplateUsed(response, name)Which template rendered
assertFormError(response, form, field, msg)A specific validation message
assertNumQueries(n)Guards against an N+1 regression
assertJSONEqual(response.content, obj)JSON payload equality

The client is a request simulator: no network, no server, full access to the response object. Use force_login() instead of posting credentials in every test, and Client(enforce_csrf_checks=True) when you specifically want to prove your form sends a token.

Forms, fixtures and coverage

# blog/tests/test_forms.py
from django.test import TestCase
from blog.forms import CommentForm

class CommentFormTests(TestCase):
    def test_rejects_links(self):
        form = CommentForm(data={"text": "see https://spam.example"})
        self.assertFalse(form.is_valid())
        self.assertIn("Links are not allowed", form.errors["text"][0])

    def test_accepts_plain_text(self):
        form = CommentForm(data={"text": "Great post, thanks."})
        self.assertTrue(form.is_valid(), form.errors)
# blog/tests/factories.py — explicit builders beat a 4000-line fixture JSON
import factory
from blog.models import Post

class PostFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Post

    title = factory.Sequence(lambda n: f"Post {n}")
    slug = factory.Sequence(lambda n: f"post-{n}")
    author = factory.SubFactory(UserFactory)

# usage
posts = PostFactory.create_batch(3, status="published")
python manage.py test --parallel --keepdb        # faster local runs
coverage run manage.py test && coverage report --skip-covered
pytest                                            # with pytest-django + DJANGO_SETTINGS_MODULE
⚠️
A test that asserts only status_code == 200 passes for an empty page, a wrong template and a page showing another user's data. Assert the observable contract: the status, the content that must appear, the redirect target, the row that must exist afterwards, and the query count you refuse to exceed. Equally, do not test Django's own machinery — test your logic, your permissions and your rendering.

FAQ

How do I test code that sends email or calls an external API?
Replace the boundary, not the logic. Django ships mail.outbox for email, and unittest.mock.patch handles HTTP clients. Assert what your code did with the result, and keep one integration test that runs against a real service on demand rather than in every commit.
Fixtures or factories?
Factories for almost everything: they are readable, only create the rows you need, and survive model changes without a regeneration step. Keep a small fixture only for genuine reference data such as countries or permission groups that you also want to load into production.

Django REST Framework: serializers and viewsets Authentication, users and permissions

Last refreshed 2026-09-18.