Testing a DRF API with APITestCase and APIClient
The Django from scratch series covered TestCase and the plain Django test client. DRF has its own thin layer on top of both — APITestCase and APIClient — built specifically for testing JSON endpoints instead of HTML pages.
APIClient vs. the plain Django test client
# tasks/tests.py
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from .models import Task
class TaskAPITests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="testpass123")
self.task = Task.objects.create(title="Existing task", owner=self.user)
def test_list_tasks_unauthenticated_denied(self):
response = self.client.get("/api/tasks/")
self.assertEqual(response.status_code, 401)APITestCase is Django's TestCase with self.client already set to an APIClient instead of the plain Django one — APIClient understands response.data (the parsed response body as a Python object, no manual json.loads() needed) and has built-in helpers for authenticating requests, both used below.
Authenticating a test request
def test_authenticated_user_can_list_tasks(self):
self.client.force_authenticate(user=self.user)
response = self.client.get("/api/tasks/")
self.assertEqual(response.status_code, 200)force_authenticate() sets request.user directly for the duration of the test, bypassing the actual token or session login flow entirely. It's not testing that login works — part 15's token endpoint deserves its own direct test for that — it's the fast way to test everything that happens after authentication without a real token in every single test.
Testing that a permission actually holds
def test_other_user_cannot_update_task(self):
other_user = User.objects.create_user(username="bob", password="testpass123")
self.client.force_authenticate(user=other_user)
response = self.client.patch(f"/api/tasks/{self.task.id}/", {"title": "Hijacked"}, format="json")
self.assertEqual(response.status_code, 403)
self.task.refresh_from_db()
self.assertEqual(self.task.title, "Existing task")This is the direct test of part 16's IsOwner permission — logged in as bob, attempting to edit alice's task, expecting a 403 and confirming the title genuinely never changed. format="json" on self.client.patch() tells APIClient to encode the request body as JSON rather than form data, matching how a real API client actually sends it.
Testing serializer validation
def test_due_date_in_the_past_rejected(self):
self.client.force_authenticate(user=self.user)
response = self.client.post("/api/tasks/", {
"title": "Bad task", "due_date": "2020-01-01",
}, format="json")
self.assertEqual(response.status_code, 400)
self.assertIn("due_date", response.data)A direct test of part 11's validate_due_date — response.data here is already a parsed dict (APIClient's upgrade over the plain client), so "due_date" in response.data checks the error landed on the right field without any manual JSON parsing.
Beyond the general advice from the Django from scratch series — logic and security matter more than trivial code — a DRF project has two categories worth deliberate coverage: every custom validate_<field>/validate() method (part 11), and every custom permission (part 16). Both are exactly the kind of logic that silently regresses when refactored, and exactly the kind a test like the one above catches immediately.
Next: API versioning — this API has one shape today; a real one eventually needs to change that shape without breaking every existing client overnight.