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 plainClient— sends form-encoded, yourJSONParserrejects 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/ checkresolve(). force_authenticate(user=None)doesn’t reset credentials — clear bothforce_authenticateandcredentials()between tests.pytestwithoutdbfixture —DatabaseError: no such table. Adddbortransactional_db.
Interview angle
- “What’s
APIClientand how is it different from Django’sClient?” — DRF subclass; defaults to JSON, supportsforce_authenticate, returns DRFResponsewith.dataas parsed Python primitives. - “
force_authenticatevsclient.loginvscredentials?” — bypass auth (fastest) / real session login / sets a header (e.g.Authorization: Token ...). Useforce_authenticateunless you’re specifically testing the auth class. - “How would you test that a list endpoint doesn’t N+1?” —
CaptureQueriesContext, assertlen(ctx) <= budget. - “How do you isolate throttle state between tests?” —
override_settingsto disable, orcache.clear()insetUp. Otherwise a previous test’s requests leak into the next. - “When would you reach for
APIRequestFactoryinstead ofAPIClient?” — unit-testing a view in isolation without URL routing. Rarely;APIClientis closer to production.