RizTech Academy logo
RizTech Academy
Building an API with DRFLesson 5 of 530 min

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:

  1. 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.
  2. The core success — list/retrieve/create/update/delete return the right status and data.
  3. Auth and permissions — the allowed and denied paths for protected endpoints.
  4. The response shape — key endpoints return the fields clients expect.
  5. 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

  1. Write an APITestCase with setUp creating patients; test the list returns 200 and the right count.
  2. Test a valid create returns 201 and increments the count; test an invalid phone returns 400, names the phone field, and creates nothing.
  3. Add IsAuthenticated to the ViewSet; test that an anonymous request is rejected and a force_authenticated one succeeds.
  4. 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.
  5. Test ?search=Asha returns one result and ?ordering=-name orders correctly.
  6. Run the suite with pytest/manage.py test and confirm all pass; deliberately break the serializer and watch the shape test catch it.

Official documentation

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