Testing Django Apps

TestCase, the test client, and a complete test covering views, auth, and a DRF endpoint.

Why Django's testing tools exist

Django's test framework builds on Python's standard unittest, adding Django-specific conveniences: a test database created and destroyed automatically for each test run, a TestCase base class that wraps each test in a transaction it rolls back afterward (so tests never see each other's data), and a Client that simulates real HTTP requests against your app without needing a running server.

TestCase and the test database

Python
# blog/tests.py
from django.test import TestCase
from .models import Author, Post

class PostModelTests(TestCase):
    def setUp(self):
        self.author = Author.objects.create(name="Ada Lovelace", email="ada@example.com")

    def test_post_str_returns_title(self):
        post = Post.objects.create(title="Hello, Django", body="...", author=self.author)
        self.assertEqual(str(post), "Hello, Django")

    def test_default_published_is_false(self):
        post = Post.objects.create(title="Draft", body="...", author=self.author)
        self.assertFalse(post.published)
Bash
python3 manage.py test

manage.py test creates a fresh test database (by default, test_<your db name>), runs every discovered TestCase, and destroys it afterward — your real development database is never touched. Each individual test method inside a TestCase runs inside its own database transaction that's rolled back once the test finishes, so setUp()'s self.author never leaks into the next test.

The test client: a complete view test

Client simulates a browser making real requests — GET, POST, following redirects — against your URL configuration, letting you test a whole view (routing, permissions, template, database effects) in one assertion-driven test:

Python
# blog/tests.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from .models import Author, Post

class PostListViewTests(TestCase):
    def setUp(self):
        self.author = Author.objects.create(name="Ada Lovelace", email="ada@example.com")
        Post.objects.create(title="Published Post", body="...", author=self.author, published=True)
        Post.objects.create(title="Draft Post", body="...", author=self.author, published=False)

    def test_post_list_shows_only_published_posts(self):
        response = self.client.get(reverse("post_list"))

        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Published Post")
        self.assertNotContains(response, "Draft Post")

class PostCreateViewTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="ada", password="s3cret-pass")

    def test_anonymous_user_is_redirected_to_login(self):
        response = self.client.get(reverse("post_create"))
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_logged_in_user_can_create_a_post(self):
        self.client.login(username="ada", password="s3cret-pass")

        response = self.client.post(reverse("post_create"), {
            "title": "New Post",
            "body": "Some content.",
            "published": True,
        })

        self.assertEqual(response.status_code, 302)   # redirected after a successful create
        self.assertTrue(Post.objects.filter(title="New Post").exists())

self.client is a Client instance every TestCase gets automatically. reverse("post_list") resolves a named URL the same way {% url %} does in a template, so a test never hardcodes a literal path that could drift out of sync with urls.py. self.client.login(...) authenticates the test client exactly like a real login form would, letting test_logged_in_user_can_create_a_post exercise the @login_required path.

Testing a DRF endpoint

DRF ships its own APIClient (a thin Client subclass) that also understands things like sending JSON bodies and API authentication directly:

Python
from rest_framework.test import APITestCase
from rest_framework import status
from django.contrib.auth.models import User

class PostAPITests(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="ada", password="s3cret-pass")

    def test_creating_a_post_requires_authentication(self):
        response = self.client.post("/api/posts/", {"title": "New", "body": "..."})
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_authenticated_user_can_create_a_post(self):
        self.client.force_authenticate(user=self.user)
        response = self.client.post("/api/posts/", {"title": "New", "body": "...", "author": self.user.id})
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

force_authenticate() is a DRF testing shortcut that skips the real authentication flow entirely (no real token or session needed) and simply attaches the given user to the request — the standard way to test an authenticated endpoint without the overhead of generating and passing a real token in every test.

Common mistakes

  • Using unittest.TestCase directly instead of Django's django.test.TestCase — the plain version has no automatic per-test transaction rollback or test database wiring, so data from one test can leak into the next.
  • Hardcoding literal URLs ("/blog/posts/1/") in tests instead of reverse("post_detail", args=[1]) — a later change to urls.py silently breaks every test relying on the old path.
  • Forgetting that self.client.get()/.post() runs through the entire request/response cycle, including middleware and permission checks — an unexpected 302 or 403 in a test is usually a real, correctly-enforced restriction, not a testing quirk.

Interview questions

Q: What does Django's TestCase do differently from plain unittest.TestCase? It wraps each test method in a database transaction that's automatically rolled back once the test finishes, so tests never see data left behind by another test, and it manages creating and destroying a dedicated test database around the whole test run. Plain unittest.TestCase has no awareness of Django's database at all — you'd have to manage isolation and cleanup yourself.

Q: What does the Django test Client let you verify that calling a view function directly can't? It sends a real, full HTTP request through Django's actual URL routing, middleware stack, and permission checks — not just the view function's return value in isolation. That means a test using self.client.get(reverse(...)) also verifies the URL is wired up correctly, that @login_required (or any other middleware) behaves as expected, and that the rendered template actually contains the expected content — a broader, more realistic check than calling the view function directly with a fake request object.