Testing a Django app: TestCase and the test client
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
# 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
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
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
python manage.py testCreating 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.
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.