Module 14: Testing in Django — Models, Views & APIs
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Understand why automated testing is non-negotiable for production code
- Write unit tests for Django models using
TestCase - Write API tests for REST endpoints using
APITestCaseandAPIClient - Test authentication flows: registration, login, token generation
- Use Django's
setUp()method to create reusable test data - Master common assertions:
assertEqual,assertTrue,assertIn - Run tests with coverage reporting to find untested code
- Build a complete test suite for your blog application
📖 1. Why Testing Matters
"Untested code is broken code."
Testing ensures that your application behaves correctly today — and continues to behave correctly after every future change. Without tests, a simple refactor can silently break user registration, post creation, or authentication.
What Django testing gives you:
- Isolated test database: Each test runs against a fresh database that is destroyed afterward
- Built-in assertions: Clean, readable ways to verify behavior
- Test client: Simulate HTTP requests without running a server
- API client: Test REST endpoints with authentication support
🏗️ 2. Testing Django Models
Model tests verify that your database logic, properties, and methods work correctly.
The Model Test Class
1# blog/tests.py 2 3from django.test import TestCase 4from django.contrib.auth import get_user_model 5from .models import Post, Category 6 7User = get_user_model() 8 9 10class PostModelTest(TestCase): 11 """ 12 Tests for the Post model's fields, methods, and properties. 13 """ 14 15 def setUp(self): 16 """ 17 setUp() runs BEFORE every individual test method. 18 Use it to create test data that multiple tests need. 19 """ 20 self.user = User.objects.create_user( 21 username='testuser', 22 email='test@example.com', 23 password='testpass123' 24 ) 25 26 self.category = Category.objects.create( 27 name='Technology', 28 slug='technology' 29 ) 30 31 self.post = Post.objects.create( 32 title='Test Post Title', 33 slug='test-post-title', 34 author=self.user, 35 category=self.category, 36 content='This is test content for the blog post.', 37 status='published' 38 ) 39 40 def test_post_creation(self): 41 """ 42 Verify that the post was created with the correct data. 43 """ 44 self.assertEqual(self.post.title, 'Test Post Title') 45 self.assertEqual(str(self.post), 'Test Post Title') 46 self.assertTrue(self.post.slug) # slug should not be empty 47 48 def test_reading_time_property(self): 49 """ 50 Verify the reading_time property returns a positive integer. 51 """ 52 self.assertEqual(self.post.reading_time, 1) 53 54 def test_post_absolute_url(self): 55 """ 56 If get_absolute_url() is defined on the model, test it here. 57 """ 58 # Example: self.assertEqual(self.post.get_absolute_url(), '/posts/test-post-title/') 59 pass
Key concepts:
| Method | Purpose |
|---|---|
setUp() | Runs before each test method. Creates fresh test data. |
setUpTestData() | Runs once for the entire class. Faster for shared read-only data. |
assertEqual(a, b) | Passes if a == b |
assertTrue(x) | Passes if x is True |
assertFalse(x) | Passes if x is False |
assertIsNone(x) | Passes if x is None |
assertIn(a, b) | Passes if a is in b |
🌐 3. Testing REST API Endpoints
For API testing, Django REST Framework provides APITestCase and APIClient — enhanced versions of Django's test tools with built-in JSON support and authentication helpers.
Testing the Post API
1# blog/tests.py 2 3from django.urls import reverse 4from rest_framework.test import APITestCase, APIClient 5from rest_framework import status 6from django.contrib.auth import get_user_model 7from .models import Post, Category 8 9User = get_user_model() 10 11 12class PostAPITest(APITestCase): 13 """ 14 Tests for the Post API endpoints: list, create, retrieve, update. 15 """ 16 17 def setUp(self): 18 self.client = APIClient() # DRF's enhanced test client 19 20 self.user = User.objects.create_user( 21 username='apitest', 22 email='api@test.com', 23 password='testpass123' 24 ) 25 26 self.category = Category.objects.create( 27 name='API Test Category', 28 slug='api-test-category' 29 ) 30 31 self.post = Post.objects.create( 32 title='API Test Post', 33 slug='api-test-post', 34 author=self.user, 35 category=self.category, 36 content='This is content specifically for API testing purposes.', 37 status='published' 38 ) 39 40 def test_get_posts_list(self): 41 """ 42 GET /api/posts/ should return a paginated list of posts. 43 """ 44 url = reverse('blog:api-posts') # Named URL from urls.py 45 response = self.client.get(url) 46 47 self.assertEqual(response.status_code, status.HTTP_200_OK) 48 self.assertEqual(len(response.data['results']), 1) 49 50 def test_create_post_authenticated(self): 51 """ 52 Authenticated users should be able to create posts via POST. 53 """ 54 # Log in the test user 55 self.client.force_authenticate(user=self.user) 56 57 url = reverse('blog:api-posts') 58 data = { 59 'title': 'New API Post', 60 'content': 'Brand new content created via the API test.', 61 'category': self.category.id, 62 'status': 'draft' 63 } 64 65 response = self.client.post(url, data) 66 67 self.assertEqual(response.status_code, status.HTTP_201_CREATED) 68 self.assertEqual(Post.objects.count(), 2) # Original + new post 69 70 def test_create_post_unauthenticated(self): 71 """ 72 Anonymous users should be FORBIDDEN from creating posts. 73 """ 74 url = reverse('blog:api-posts') 75 data = { 76 'title': 'Hacked Post', 77 'content': 'This should not be allowed.' 78 } 79 80 response = self.client.post(url, data) 81 82 self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 83 84 def test_update_post_by_author(self): 85 """ 86 The post author should be able to update their own post. 87 """ 88 self.client.force_authenticate(user=self.user) 89 90 url = reverse('blog:api-post-detail', kwargs={'slug': self.post.slug}) 91 data = { 92 'title': 'Updated Title', 93 'content': 'Updated content via PATCH request.' 94 } 95 96 response = self.client.patch(url, data) 97 98 self.assertEqual(response.status_code, status.HTTP_200_OK) 99 100 # Refresh the post from the database to verify changes persisted 101 self.post.refresh_from_db() 102 self.assertEqual(self.post.title, 'Updated Title')
API Testing Tools:
| Tool | Purpose |
|---|---|
APIClient | Enhanced HTTP client that supports JSON and authentication |
force_authenticate(user) | Log in a user without going through the login endpoint |
client.get(url) | Simulate a GET request |
client.post(url, data) | Simulate a POST request |
client.patch(url, data) | Simulate a PATCH request |
client.delete(url) | Simulate a DELETE request |
status.HTTP_200_OK | Human-readable status code constants |
refresh_from_db() | Reload model data from the database after an API change |
🔐 4. Testing Authentication
Authentication is the most critical part of your application to test. A bug here means anyone can access private data.
1# users/tests.py (or api/tests.py) 2 3from django.urls import reverse 4from rest_framework.test import APITestCase 5from rest_framework import status 6from django.contrib.auth import get_user_model 7 8User = get_user_model() 9 10 11class AuthenticationTest(APITestCase): 12 """ 13 Tests for user registration, login, and token generation. 14 """ 15 16 def test_user_registration(self): 17 """ 18 POST /api/auth/register/ should create a new user. 19 """ 20 url = reverse('api-register') 21 data = { 22 'username': 'newuser', 23 'email': 'new@example.com', 24 'first_name': 'New', 25 'last_name': 'User', 26 'password': 'securepass123', 27 'password_confirm': 'securepass123' 28 } 29 30 response = self.client.post(url, data) 31 32 self.assertEqual(response.status_code, status.HTTP_201_CREATED) 33 self.assertEqual(User.objects.count(), 1) 34 self.assertEqual(User.objects.get().email, 'new@example.com') 35 36 def test_jwt_login(self): 37 """ 38 POST /api/auth/login/ should return access and refresh tokens. 39 """ 40 # Create a user first 41 User.objects.create_user( 42 username='jwtuser', 43 email='jwt@test.com', 44 password='jwtpass123' 45 ) 46 47 url = reverse('token_obtain_pair') 48 response = self.client.post(url, { 49 'email': 'jwt@test.com', 50 'password': 'jwtpass123' 51 }) 52 53 self.assertEqual(response.status_code, status.HTTP_200_OK) 54 self.assertIn('access', response.data) 55 self.assertIn('refresh', response.data) 56 self.assertIn('user', response.data) 57 58 def test_login_with_wrong_password(self): 59 """ 60 Login with invalid credentials should return 401 Unauthorized. 61 """ 62 User.objects.create_user( 63 username='wronguser', 64 email='wrong@test.com', 65 password='correctpass' 66 ) 67 68 url = reverse('token_obtain_pair') 69 response = self.client.post(url, { 70 'email': 'wrong@test.com', 71 'password': 'wrongpass' 72 }) 73 74 self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
🚀 5. Running Your Tests
Django provides a powerful test runner through manage.py.
1# Run all tests across all apps 2python manage.py test 3 4# Run tests for a specific app 5python manage.py test blog 6 7# Run a specific test class 8python manage.py test blog.tests.PostModelTest 9 10# Run a specific test method 11python manage.py test blog.tests.PostModelTest.test_reading_time_property 12 13# Run tests with verbose output 14python manage.py test --verbosity=2 15 16# Run tests and stop at the first failure 17python manage.py test --failfast
📊 6. Measuring Test Coverage
Coverage tells you what percentage of your code is executed during tests. Aim for 80%+ coverage on critical paths.
1# Install coverage tool 2pip install coverage 3 4# Run tests with coverage tracking 5coverage run --source='.' manage.py test 6 7# View coverage report in terminal 8coverage report 9 10# Generate an interactive HTML report 11coverage html
After running coverage html, open htmlcov/index.html in your browser to see exactly which lines are untested.
Sample coverage output:
Name Stmts Miss Cover
-------------------------------------------
blog/models.py 45 3 93%
blog/views.py 80 25 69%
blog/serializers.py 60 5 92%
blog/urls.py 10 0 100%
-------------------------------------------
TOTAL 195 33 83%
🧪 7. Practice Task — Test the Comment Model & API
Task Requirements
Write tests for:
- Model test: A comment is created correctly and linked to a post and author
- API test: Authenticated users can create comments on a post
- API test: Unauthenticated users cannot create comments
- API test: The comment count on a post increases after creation
Solution
1# blog/tests.py 2 3from django.test import TestCase 4from django.urls import reverse 5from rest_framework.test import APITestCase, APIClient 6from rest_framework import status 7from django.contrib.auth import get_user_model 8from .models import Post, Category, Comment 9 10User = get_user_model() 11 12 13class CommentModelTest(TestCase): 14 """ 15 Tests for the Comment model. 16 """ 17 18 def setUp(self): 19 self.user = User.objects.create_user( 20 username='commenter', 21 email='comment@test.com', 22 password='pass123' 23 ) 24 self.category = Category.objects.create(name='Tech', slug='tech') 25 self.post = Post.objects.create( 26 title='Commentable Post', 27 slug='commentable-post', 28 author=self.user, 29 category=self.category, 30 content='A post to comment on.', 31 status='published' 32 ) 33 34 def test_comment_creation(self): 35 """ 36 Verify that a comment is created and linked correctly. 37 """ 38 comment = Comment.objects.create( 39 post=self.post, 40 author=self.user, 41 content='This is a test comment.' 42 ) 43 44 self.assertEqual(comment.content, 'This is a test comment.') 45 self.assertEqual(comment.post, self.post) 46 self.assertEqual(comment.author, self.user) 47 self.assertFalse(comment.is_approved) # Default should be False 48 49 50class CommentAPITest(APITestCase): 51 """ 52 Tests for the Comment API endpoints. 53 """ 54 55 def setUp(self): 56 self.client = APIClient() 57 self.user = User.objects.create_user( 58 username='api_commenter', 59 email='api_comment@test.com', 60 password='pass123' 61 ) 62 self.category = Category.objects.create(name='Tech', slug='tech') 63 self.post = Post.objects.create( 64 title='API Comment Post', 65 slug='api-comment-post', 66 author=self.user, 67 category=self.category, 68 content='Post for API comment tests.', 69 status='published' 70 ) 71 72 def test_create_comment_authenticated(self): 73 """ 74 Authenticated users should be able to create comments. 75 """ 76 self.client.force_authenticate(user=self.user) 77 78 url = reverse('blog:api-post-comments', kwargs={'post_slug': self.post.slug}) 79 data = {'content': 'Great article!'} 80 81 response = self.client.post(url, data) 82 83 self.assertEqual(response.status_code, status.HTTP_201_CREATED) 84 self.assertEqual(Comment.objects.count(), 1) 85 self.assertEqual(Comment.objects.first().content, 'Great article!') 86 87 def test_create_comment_unauthenticated(self): 88 """ 89 Anonymous users should not be able to create comments. 90 """ 91 url = reverse('blog:api-post-comments', kwargs={'post_slug': self.post.slug}) 92 data = {'content': 'Anonymous spam'} 93 94 response = self.client.post(url, data) 95 96 self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 97 self.assertEqual(Comment.objects.count(), 0) 98 99 def test_comment_count_increases(self): 100 """ 101 Creating a comment should increase the post's comment count. 102 """ 103 self.client.force_authenticate(user=self.user) 104 105 initial_count = self.post.comments.count() 106 107 url = reverse('blog:api-post-comments', kwargs={'post_slug': self.post.slug}) 108 self.client.post(url, {'content': 'Another comment'}) 109 110 self.post.refresh_from_db() 111 self.assertEqual(self.post.comments.count(), initial_count + 1)
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
setUp vs setUpTestData | Tests run slowly | Use setUpTestData for read-only shared data |
Forgetting refresh_from_db() | Test sees old data after API update | Call it after PATCH/PUT operations |
Testing with client instead of APIClient | JSON parsing issues | Use APIClient for DRF endpoints |
reverse() with wrong URL name | NoReverseMatch | Ensure the URL name matches urls.py exactly |
force_authenticate after the request | Auth not applied | Authenticate before making the request |
| Not testing the error case | Only happy path covered | Always test what happens with invalid data |
status.HTTP_200 (wrong constant) | AttributeError | Use status.HTTP_200_OK |
self.client.post(data) without format | Form data instead of JSON | Use for API requests |
✅ Module 14 Summary
| Concept | Key Takeaway |
|---|---|
TestCase | Django's base class for unit testing |
APITestCase | DRF's base class for API endpoint testing |
APIClient | Enhanced HTTP client for REST API tests |
setUp() | Runs before every test method (fresh data) |
setUpTestData() | Runs once per test class (shared data) |
force_authenticate() | Log in a user without the login endpoint |
assertEqual(a, b) | Verify two values match |
assertIn(a, b) | Verify a value exists in a collection |
refresh_from_db() | Reload model instance after external changes |
reverse('url-name') | Generate URLs by name instead of hardcoding |
status.HTTP_200_OK | Readable HTTP status code constants |
coverage run | Track which code lines are executed by tests |
🚪 What's Next?
In Module 15, we will cover Deployment & Production. You'll learn to configure Django for production environments, set up Gunicorn as your WSGI server, write a Dockerfile for containerization, configure WhiteNoise for static files, and deploy your application to cloud platforms like Render, Railway, or AWS.
Before proceeding, make sure:
- You have tests for all your models (Post, Category, Comment, User)
- Your API tests cover list, create, retrieve, update, and delete endpoints
- Authentication tests verify both success and failure cases
- You can run
python manage.py testwith zero failures - You installed
coverageand generated an HTML report - You understand the difference between
setUp()andsetUpTestData()
Your code is now bulletproof with comprehensive tests. Ready to ship to production? 🚢🚀