Testing DRF APIs

5 interview angles 4 min read source

Testing DRF APIs

DRF ships its own test client (APIClient) and helpers. They’re better than Django’s plain Client for API work because they handle JSON encoding, content negotiation, and authentication shortcuts.

The four DRF test classes/helpers

Tool Use
APIClient Test client. Subclass of Django’s Client, sends application/json by default
APITestCase django.test.TestCase with self.client = APIClient()
APIRequestFactory Build request objects without going through the URL resolver — for unit-testing views
force_authenticate(request_or_client, user, token) Skip the auth flow in tests

Basic test

from rest_framework import status
from rest_framework.test import APITestCase

class BookAPITest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user("alice", password="x")
        self.client.force_authenticate(user=self.user)

    def test_list(self):
        Book.objects.create(title="LOTR", author=self.user)
        resp = self.client.get("/api/books/")
        assert resp.status_code == status.HTTP_200_OK
        assert resp.json()["count"] == 1

    def test_create(self):
        resp = self.client.post("/api/books/", {"title": "Hobbit"}, format="json")
        assert resp.status_code == status.HTTP_201_CREATED
        assert Book.objects.count() == 1

format="json" is the default for APIClient but be explicit — Django’s Client defaults to form-encoded.

With pytest (preferred)

import pytest
from rest_framework.test import APIClient

@pytest.fixture
def api_client():
    return APIClient()

@pytest.fixture
def auth_client(api_client, db):
    user = User.objects.create_user("alice", password="x")
    api_client.force_authenticate(user=user)
    return api_client

def test_list_books(auth_client):
    resp = auth_client.get("/api/books/")
    assert resp.status_code == 200

Note db fixture (from pytest-django) — required for any test that touches the ORM.

force_authenticate vs real login

self.client.force_authenticate(user=alice)              # bypasses auth completely
self.client.credentials(HTTP_AUTHORIZATION=f"Token {t}")  # adds a real header
self.client.login(username="alice", password="x")       # real session login

force_authenticate is fastest and most common. Use real credentials when you specifically need to test the auth class itself (e.g. token expiry, signature validation).

To clear:

self.client.force_authenticate(user=None)
self.client.credentials()  # clear any extra headers

Asserting JSON content

resp = self.client.get("/api/books/1/")
assert resp.status_code == 200
assert resp.json() == {"id": 1, "title": "LOTR", "author": 5}

Use resp.json() (DRF responses) or resp.data (works on both, returns Python primitives without re-parsing).

For a partial match:

data = resp.json()
assert data["title"] == "LOTR"
assert "author" in data

Testing per-action permissions

def test_list_is_public(self, api_client):
    resp = api_client.get("/api/books/")
    assert resp.status_code == 200

def test_destroy_requires_auth(self, api_client):
    resp = api_client.delete("/api/books/1/")
    assert resp.status_code == 401

def test_destroy_requires_owner(self, auth_client):
    other_user_book = Book.objects.create(title="x", author=other_user)
    resp = auth_client.delete(f"/api/books/{other_user_book.pk}/")
    assert resp.status_code == 403

A good rule: every endpoint gets a 401-when-anon test and a 403-when-wrong-user test, in addition to the happy path.

Testing serializer validation directly

For unit tests, instantiate the serializer without going through HTTP:

def test_email_required():
    serializer = SignupSerializer(data={"name": "Alice"})
    assert not serializer.is_valid()
    assert "email" in serializer.errors

def test_save_creates_user():
    serializer = SignupSerializer(data={"email": "a@b.com", "password": "x"})
    assert serializer.is_valid(), serializer.errors
    user = serializer.save()
    assert user.email == "a@b.com"

Faster than going through the view — no URL resolution, no auth, no permissions. Good for testing complex validate() logic.

Testing with APIRequestFactory

For testing a view in isolation (no URL routing):

from rest_framework.test import APIRequestFactory, force_authenticate

factory = APIRequestFactory()

def test_view_directly():
    request = factory.get("/api/books/")
    force_authenticate(request, user=alice)
    view = BookViewSet.as_view({"get": "list"})
    response = view(request)
    response.render()
    assert response.status_code == 200

Use case: testing a view’s behavior without depending on URL config. Most tests should use APIClient instead — closer to production.

Testing N+1

from django.test.utils import CaptureQueriesContext
from django.db import connection

def test_list_no_nplus1(auth_client):
    Comment.objects.bulk_create([Comment(body=f"c{i}", post=p) for i in range(20)])
    with CaptureQueriesContext(connection) as ctx:
        resp = auth_client.get("/api/comments/")
    assert resp.status_code == 200
    assert len(ctx) <= 5  # adjust to your budget

Lock the query budget — when someone adds a SerializerMethodField that triggers a query, this test fails immediately. See 12_performance_n_plus_1.md.

Overriding throttles in tests

Without this, throttle tests are flaky and slow. Two options:

@override_settings(REST_FRAMEWORK={"DEFAULT_THROTTLE_CLASSES": []})
class MyTests(APITestCase):
    ...

Or specifically test the throttle:

@override_settings(REST_FRAMEWORK={
    "DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.UserRateThrottle"],
    "DEFAULT_THROTTLE_RATES": {"user": "2/min"},
})
def test_throttled(auth_client):
    auth_client.get("/api/things/")
    auth_client.get("/api/things/")
    resp = auth_client.get("/api/things/")
    assert resp.status_code == 429

The throttle cache also needs clearing between tests if you use a shared cache backend:

from django.core.cache import cache
def setUp(self): cache.clear()

Testing exceptions

def test_book_not_found(auth_client):
    resp = auth_client.get("/api/books/99999/")
    assert resp.status_code == 404
    assert resp.json() == {"detail": "Not found."}

For a custom exception handler (14_exception_handling.md), assert the envelope your handler produces.

Pitfalls

  • Forgetting format="json" on Django’s plain Client — sends form-encoded, your JSONParser rejects it with 415.
  • Sharing throttle cache between tests — flaky failures. Either disable throttles in tests or clear the cache in setUp.
  • Testing through URLs you didn’t include — DRF returns 404, you blame the view. Print resp.url / check resolve().
  • force_authenticate(user=None) doesn’t reset credentials — clear both force_authenticate and credentials() between tests.
  • pytest without db fixtureDatabaseError: no such table. Add db or transactional_db.

Interview angle

  • “What’s APIClient and how is it different from Django’s Client?” — DRF subclass; defaults to JSON, supports force_authenticate, returns DRF Response with .data as parsed Python primitives.
  • force_authenticate vs client.login vs credentials?” — bypass auth (fastest) / real session login / sets a header (e.g. Authorization: Token ...). Use force_authenticate unless you’re specifically testing the auth class.
  • “How would you test that a list endpoint doesn’t N+1?”CaptureQueriesContext, assert len(ctx) <= budget.
  • “How do you isolate throttle state between tests?”override_settings to disable, or cache.clear() in setUp. Otherwise a previous test’s requests leak into the next.
  • “When would you reach for APIRequestFactory instead of APIClient?” — unit-testing a view in isolation without URL routing. Rarely; APIClient is closer to production.