~/TechPurAI
~/tutorials/django-from-scratch/testing-a-django-app
intermediate·part 21 of 22·3 min read

Testing a Django app: TestCase and the test client

Updated Aug 15, 2026Python · Django

Everything in this series so far has been checked by clicking around in a browser. That works while a project is small enough to hold in your head — it stops working the moment a change to PostUpdateView might have quietly broken permissions from part 17 without anyone noticing until a user hits it. Tests are what catch that instead.

A model test

python
# blog/tests.py
from django.test import TestCase
from django.contrib.auth.models import User
from .models import Post

class PostModelTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="alice", password="testpass123")

    def test_str_returns_title(self):
        post = Post.objects.create(
            title="Test Post", slug="test-post", content="Body", author=self.user,
        )
        self.assertEqual(str(post), "Test Post")

    def test_default_ordering_is_newest_first(self):
        older = Post.objects.create(title="Older", slug="older", content="...", author=self.user)
        newer = Post.objects.create(title="Newer", slug="newer", content="...", author=self.user)
        posts = list(Post.objects.all())
        self.assertEqual(posts[0], newer)
        self.assertEqual(posts[1], older)

django.test.TestCase wraps every test method in a database transaction that rolls back afterward — each test starts from a clean slate, and creating a Post in one test never leaks into the next. setUp() runs before every single test method in the class; creating the test user there instead of in each test avoids repeating it three times.

Testing a view with the test client

python
class PostListViewTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="alice", password="testpass123")
        Post.objects.create(
            title="Published", slug="published", content="...",
            author=self.user, published=True,
        )
        Post.objects.create(
            title="Draft", slug="draft", content="...",
            author=self.user, published=False,
        )

    def test_only_published_posts_appear(self):
        response = self.client.get("/")
        self.assertContains(response, "Published")
        self.assertNotContains(response, "Draft")

self.client is Django's test client — it makes a real request through the full URL-routing-and-view stack, the same path an actual browser request takes, and returns a response object to make assertions against. assertContains/assertNotContains check the rendered HTML directly, which is exactly what this test needs: proof that PostListView's queryset = Post.objects.filter(published=True) filter from part 9 is actually working, not just that the view returns a 200.

Testing that permissions actually hold

python
class PostDeletePermissionTests(TestCase):
    def setUp(self):
        self.owner = User.objects.create_user(username="alice", password="testpass123")
        self.other = User.objects.create_user(username="bob", password="testpass123")
        self.post = Post.objects.create(
            title="Alice's Post", slug="alices-post", content="...", author=self.owner,
        )

    def test_other_user_cannot_delete(self):
        self.client.login(username="bob", password="testpass123")
        response = self.client.post(f"/post/{self.post.slug}/delete/")
        self.assertEqual(response.status_code, 404)
        self.assertTrue(Post.objects.filter(slug="alices-post").exists())

This is the test that would have caught a regression in part 17's get_queryset() override immediately, instead of relying on someone noticing in production that any logged-in user could delete anyone's post. self.client.login() authenticates the test client as a specific user for subsequent requests — logging in as bob and confirming he gets a 404 (not a 403, since get_queryset() makes the post genuinely not exist from his perspective) attempting to delete alice's post is a direct test of the exact security boundary part 17 built.

Running the tests

bash
python manage.py test
text
Creating test database for alias 'default'...
....
----------------------------------------------------------------------
Ran 4 tests in 0.312s

OK
Destroying test database for alias 'default'...

manage.py test creates a real, separate database, runs every TestCase, and destroys it afterward — tests never touch the actual development db.sqlite3, so running them repeatedly never accumulates leftover test data.

What's worth testing here

Not every line needs a test. The ones that matter most: anything with real logic (the ordering, the published filter) and anything security-related (the permission check above) — exactly the two categories these examples cover. A __str__ method returning a hardcoded string barely needs one; a queryset filter deciding who can see or delete what absolutely does.

Last part: taking this from runserver on a laptop to an actual deployed site.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous20. Pagination: splitting a long list across pagesnext →22. Deploying Django: production settings and going live