Django

Testing Django applications

Django's test suite runs against a real, throwaway database and gives you a fake client to make requests with — the two pieces that make testing views, models, and forms genuinely realistic instead of mocked.

Intermediate

4 min read

Why Django tests aren't just plain unit tests

from django.test import TestCase
 
class ArticleModelTests(TestCase):
    def test_str_returns_title(self):
        article = Article.objects.create(title="Hello", body="World")
        self.assertEqual(str(article), "Hello")

django.test.TestCase (a subclass of Python's built-in unittest.TestCase) does something the plain version doesn't: it creates a real, throwaway test database before the test run, and wraps every individual test in a database transaction that's rolled back at the end of that test. Article.objects.create(...) above genuinely hits a database — real SQL runs — but nothing written during a test persists into the next test or into the actual development database. This is what makes it safe to freely create, query, and modify real model instances in tests without any risk of interference between tests or contamination of real data.

The test client — simulating requests without a running server

from django.test import TestCase
from django.urls import reverse
 
class ArticleListViewTests(TestCase):
    def test_returns_200_and_lists_articles(self):
        Article.objects.create(title="First post", body="...")
 
        response = self.client.get(reverse("article-list"))
 
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "First post")

self.client is a fake HTTP client built into every TestCaseself.client.get(url) runs the exact same view code a real browser request would trigger (URL routing, the view function, template rendering), but in-process, with no real network call and no actual server running. reverse("article-list") looks up the URL for a named route rather than hardcoding the path string, so the test doesn't break if the URL pattern itself changes later — only the urls.py name has to stay stable. assertContains checks both the status code and that specific text appears in the rendered response body, which is usually what you actually want to confirm: not just "didn't crash," but "the right content is actually on the page."

Testing a form's validation directly, without going through a view

class ArticleFormTests(TestCase):
    def test_rejects_empty_title(self):
        form = ArticleForm(data={"title": "", "body": "some content"})
        self.assertFalse(form.is_valid())
        self.assertIn("title", form.errors)
 
    def test_accepts_valid_data(self):
        form = ArticleForm(data={"title": "Hello", "body": "World"})
        self.assertTrue(form.is_valid())

A form's validation logic can be tested in complete isolation from any view or HTTP request at all — construct the form directly with a data dict, check is_valid() and form.errors. This is deliberately narrower and faster than testing the same validation through self.client.post(...) to a view — when the thing actually being tested is "does this form reject an empty title," testing the form directly is both simpler to write and clearer about what's actually being verified, versus a full request/response round trip that also exercises URL routing and template rendering as an unnecessary side effect.

Testing a POST request that creates something

class ArticleCreateTests(TestCase):
    def test_post_creates_article(self):
        response = self.client.post(reverse("article-create"), {
            "title": "New article",
            "body": "Some content",
        })
 
        self.assertEqual(Article.objects.count(), 1)
        self.assertEqual(Article.objects.first().title, "New article")

This exercises the entire real path: URL routing to the view, the view constructing a form from POST data, form validation, and .save() actually persisting a row — checking Article.objects.count() afterward confirms the whole chain worked, not just that the view returned some response. This is meaningfully different from the plain form test above: it's verifying the view's behavior (does POSTing here actually create a database row), not just the form's validation logic in isolation.

Fixtures and setUp — avoiding repeated boilerplate across tests

class ArticleViewTests(TestCase):
    def setUp(self):
        self.author = User.objects.create_user(username="ada", password="test123")
        self.article = Article.objects.create(title="Existing post", author=self.author)
 
    def test_detail_view_shows_title(self):
        response = self.client.get(reverse("article-detail", args=[self.article.pk]))
        self.assertContains(response, "Existing post")
 
    def test_delete_requires_login(self):
        response = self.client.post(reverse("article-delete", args=[self.article.pk]))
        self.assertEqual(response.status_code, 302)   # redirected to login, not deleted

setUp() runs before every single test method in the class — creating shared test data once per test (not once for the whole class) means each test gets a clean, independent copy, without repeating the same User.objects.create_user(...) and Article.objects.create(...) lines in every test method. This is ordinary test hygiene, not Django-specific, but it shows up constantly in real Django test suites because so many tests need at least one existing object to test against.

Why this matters more in Django specifically than in many other frameworks

Django's ORM, URL routing, template rendering, and forms are all deeply interconnected — a bug in a urls.py change, a template typo, or a form field rename can each silently break a page without raising any Python exception at all (the page just renders wrong, or a link goes nowhere). self.client.get(...) combined with assertContains/assertEqual(response.status_code, ...) catches exactly this class of bug, which pure unit tests of isolated functions wouldn't, because the failure only exists in how the pieces are wired together, not in any one piece considered alone.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why can Article.objects.create(...) run freely inside a django.test.TestCase without leaking data into other tests?

2. What does self.client.get(reverse('article-list')) actually exercise?

3. Why use reverse('article-list') instead of hardcoding the URL string in a test?

4. Why might a test construct ArticleForm(data={...}) directly instead of always going through self.client.post to a view?