~/TechPurAI
~/tutorials/django-rest-framework/testing-drf-apis
intermediate·part 19 of 22·2 min read

Testing a DRF API with APITestCase and APIClient

Updated Aug 16, 2026Python · Django

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

python
# 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

python
    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

python
    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

python
    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.

What's worth testing in a DRF project specifically

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.

VK

Vijay Kumar

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

LinkedIn ↗
← previous18. The browsable API: what it is and why it mattersnext →20. API versioning: URL and header-based strategies