Testing your API
An API is a contract with clients you may never meet — a mobile app, another team's service — and if you
break it silently, they break in production. Tests are how you keep the contract. DRF gives you an
APIClient and APITestCase that make testing endpoints straightforward: make a request, assert on the
status code and the JSON. This lesson is what to test on an API, and how, so a change that breaks the
contract fails a test instead of a customer.
The tools: APITestCase and APIClient
DRF's APITestCase is Django's TestCase with an APIClient (self.client) that speaks JSON and handles
DRF authentication:
from rest_framework.test import APITestCase
from rest_framework import status
from patients.models import Patient
class PatientAPITests(APITestCase):
def setUp(self):
Patient.objects.create(name="Asha", phone="09812345678", city="Pune")
Patient.objects.create(name="Bhavna", phone="09811111111", city="Mumbai")
def test_list_returns_all_patients(self):
response = self.client.get("/api/patients/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()), 2)
Each test runs in its own transaction that is rolled back afterwards, so tests do not pollute each other or
your data. self.client.get(...) returns a response with .status_code and .json(). Use the status
constants (status.HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST) rather than bare numbers — they
read clearly and are self-documenting. Verified against Nidaan's API: the list endpoint returns 200 with two
patients.
Test the whole lifecycle, especially the failures
An API has more surface than an HTML page — every CRUD action, each with success and failure cases. Test them all, and weight the failures, because that is where contracts break:
def test_create_valid_patient(self):
response = self.client.post(
"/api/patients/",
{"name": "Chetan", "phone": "09812345000", "city": "Nagpur"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) # verified: 201
self.assertEqual(Patient.objects.count(), 3)
def test_create_rejects_bad_phone(self):
response = self.client.post(
"/api/patients/",
{"name": "Bad", "phone": "12", "city": "Pune"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # verified: 400
self.assertIn("phone", response.json()) # error names the field
self.assertEqual(Patient.objects.count(), 2) # nothing created
Verified: a valid create returns 201 and increases the count; an invalid phone returns 400 with
{"phone": [...]} and creates nothing. That second test is the important one — it asserts the API rejects
bad input with the right status and a useful error, and does not persist it. Test that the API says no
correctly, not only that it says yes; a client relies on a 400 with a field error to show the user what to
fix.
Test authentication and permissions
If an endpoint requires authentication, test both the allowed and the denied path — a broken permission check is a security bug, and a test is how you catch it:
def test_list_requires_authentication(self):
self.client.logout() # or simply do not authenticate
response = self.client.get("/api/patients/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_authenticated_user_can_list(self):
user = User.objects.create_user("staff", password="pw-123456")
self.client.force_authenticate(user=user) # authenticate without tokens/passwords in the test
response = self.client.get("/api/patients/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.client.force_authenticate(user=user) logs a user in for the test without going through tokens or
login — the clean way to test an endpoint as a particular user. Test the denial (anonymous → 403) as
carefully as the success: "an unauthenticated request is rejected" is exactly the assertion that catches a
permission you accidentally removed.
Assert on the response shape, not just the status
The status code is half the contract; the JSON shape is the other half. Clients depend on specific field names, so assert them:
def test_patient_shape(self):
response = self.client.get(f"/api/patients/{self.patient.id}/")
data = response.json()
self.assertEqual(
set(data.keys()), {"id", "name", "phone", "city"}, # the exact contract
)
This test fails if someone adds, removes or renames a serializer field — which is precisely when you want to be warned, because that is a contract change that could break clients. It is the API equivalent of the type-level tests idea: assert the shape the clients depend on, so a change to it is a deliberate, visible decision, not a silent break. You need not assert every field on every test, but pin the response shape of your key endpoints somewhere.
What to test, in priority order
For each endpoint, a good suite covers, roughly in order of value:
- The failure cases — invalid input returns 400 with useful errors; unauthorised access returns 403/401; a missing object returns 404. Highest value, most often skipped.
- The core success — list/retrieve/create/update/delete return the right status and data.
- Auth and permissions — the allowed and denied paths for protected endpoints.
- The response shape — key endpoints return the fields clients expect.
- Filtering/pagination —
?search=,?ordering=, pagination behave as documented.
Test the failures first, because they are where APIs actually break contracts and where a test earns its keep. A suite that only checks the happy path passes right up until a client sends something unexpected — and then your users find the bug instead of your CI.
Check your work
The tools. APITestCase (transaction-isolated) with self.client (an APIClient speaking JSON);
assert on .status_code (use status.HTTP_* constants) and .json().
Test the lifecycle and the failures. Every CRUD action, success and failure — verified: valid create
201 (+1 row), invalid phone 400 with {"phone": [...]} and no row created. Weight the failure cases.
Test auth. Both allowed and denied paths; force_authenticate(user=...) logs a user in for the test
without tokens/passwords; assert anonymous requests are rejected.
Assert the response shape. Pin the field set of key endpoints so an added/removed/renamed serializer field fails a test — the contract change becomes visible.
Priority order. Failure cases first, then core success, then auth, then shape, then filtering/pagination — failures are where contracts break.
Practice
- Write an
APITestCasewithsetUpcreating patients; test the list returns 200 and the right count. - Test a valid create returns 201 and increments the count; test an invalid phone returns 400, names the
phonefield, and creates nothing. - Add
IsAuthenticatedto the ViewSet; test that an anonymous request is rejected and aforce_authenticated one succeeds. - Write a test asserting the exact key set of a patient's JSON; then add a field to the serializer and watch the test fail — the contract change made visible.
- Test
?search=Ashareturns one result and?ordering=-nameorders correctly. - Run the suite with
pytest/manage.py testand confirm all pass; deliberately break the serializer and watch the shape test catch it.
Official documentation
- DRF — Testing —
APITestCase,APIClient,force_authenticate. - Django — Testing tools — The test client and assertions DRF builds on.
- DRF — Status codes — The
status.HTTP_*constants.
Next: async views and the ASGI story.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship