skip to content

Tests & Schemas

DRF ships APIClient, APIRequestFactory and APITestCase for endpoint tests, and its own schema generation is deprecated in favour of third-party tools. Interviewers ask how an API contract is checked.

part ofDjango REST Frameworkoverview, primer and where to startread it →
on this pageshow

explore

questions

9

In Django REST Framework, how do you write an APITestCase for an authenticated create endpoint, and what should it assert?

level: juniorimportance: must knowfreq 72%

answer

  1. self.client is already an APIClient
  2. skip login, keep the request
  3. encode the body as JSON
  4. status, body, then the database row

basics

~10 s

Subclass APITestCase, call self.client.force_authenticate(user=user), POST the payload with format='json', then assert status 201, the key fields of response.data, and that the database row exists with the right owner.

solid answer

~30 s

I subclass `APITestCase`, whose `self.client` is an `APIClient`, and call `self.client.force_authenticate(user=self.user)` so the request arrives authenticated without a real login. I post with `self.client.post(reverse('claim-list'), payload, format='json')` so nested data and nulls survive. Then I assert three things: `response.status_code == status.HTTP_201_CREATED` with `response.data` as the failure message, the fields the client needs in `response.data` (the new `id`, the server-set `owner`), and the row itself in the database with `owner == self.user`. I add a second test proving an anonymous POST is rejected and creates nothing, because DRF's default permission is `AllowAny`.

code

python · 33 lines
python
from decimal import Decimal

from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase

from claims.models import ExpenseClaim


class CreateExpenseClaimTests(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user(username="dana")

    def test_authenticated_user_creates_claim(self):
        self.client.force_authenticate(user=self.user)
        payload = {"amount": "42.50", "description": "Train ticket"}

        response = self.client.post(reverse("claim-list"), payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
        self.assertEqual(response.data["amount"], "42.50")
        claim = ExpenseClaim.objects.get(pk=response.data["id"])
        self.assertEqual(claim.owner, self.user)
        self.assertEqual(claim.amount, Decimal("42.50"))

    def test_anonymous_post_is_rejected(self):
        response = self.client.post(reverse("claim-list"), {"amount": "1.00"}, format="json")

        # SessionAuthentication is first in DRF's defaults, so this is 403, not 401.
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
        self.assertFalse(ExpenseClaim.objects.exists())

go deeper

for a junior

Recall the pieces: APITestCase gives you an APIClient, force_authenticate(user=...) logs in for the test, format='json' encodes the body, and you assert status plus data.

for a middle

Explain why the database row is asserted separately from response.data, and why an anonymous request gets 403 rather than 401 under DRF's default authentication classes.

for a senior

Show you separate fast business-logic tests using force_authenticate from a small set of real-authentication tests, and that you catch AllowAny left as the default permission.

for a principal

Frame the team convention: which assertions every create endpoint test must carry, and where the forced-authentication shortcut is allowed versus where real credentials are required.

## What the question is really checking An interviewer who asks this wants to see that you can test a DRF endpoint **through its public surface** — URL, HTTP method, request body, status code, response body — and that you also check the **side effect** the endpoint exists for: a row in the database, owned by the right user. The running example here is an expense-claims API: `POST /api/claims/` creates an `ExpenseClaim` whose `owner` is taken from `request.user`, never from the payload. Three pieces of `rest_framework.test` do the work: - **`APITestCase`** — Django's `TestCase` with `client_class = APIClient`, so `self.client` inside every test method is a fresh **`APIClient`**. Each test runs inside a transaction that is rolled back afterwards (that behaviour belongs to Django's `TestCase`, which `APITestCase` inherits unchanged). - **`APIClient.force_authenticate(user=...)`** — marks every following request from that client as coming from `user`, without building a token, session or password login. - **`format="json"`** on `post()` — encodes the payload with DRF's `JSONRenderer` and sets `Content-Type: application/json`, instead of the default multipart form encoding. ## The shape of the test 1. **Arrange** — create the user once, ideally in `setUpTestData`, and call `self.client.force_authenticate(user=self.user)` in the test. 2. **Act** — `self.client.post(reverse("claim-list"), payload, format="json")`. `reverse()` with the router's name (`<basename>-list`) keeps the test from hard-coding the URL. 3. **Assert the status** — `self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)`. Passing `response.data` as the message means a failure prints DRF's validation errors instead of just "400 != 201". 4. **Assert the body** — `response.data` is the primitive data the view put into its `Response` (for a `ModelViewSet.create`, the serializer's `.data`). Check the fields the client relies on, such as the new `id` and the server-set `owner`. 5. **Assert the side effect** — load the row from the database and check `owner == self.user`. A response can say 201 while `perform_create()` forgot to pass `owner=self.request.user`, if the serializer happens to accept an owner from the payload. ## The negative test belongs in the same class A create endpoint needs at least one test proving that **an anonymous client cannot create anything**. This is where many suites discover that the view never declared `permission_classes`: DRF's default `DEFAULT_PERMISSION_CLASSES` is `['rest_framework.permissions.AllowAny']`, so without `IsAuthenticated` on the view (or in `REST_FRAMEWORK` settings) the anonymous POST reaches `perform_create()`. Which status the anonymous POST gets depends on the **first authentication class** of the view: | First authentication class | Sends `WWW-Authenticate`? | Anonymous request is rejected with | |---|---|---| | `SessionAuthentication` (first in DRF's defaults) | No | `403 Forbidden` | | `BasicAuthentication` | Yes (`Basic realm="api"`) | `401 Unauthorized` | | `TokenAuthentication` | Yes (`Token`) | `401 Unauthorized` | DRF raises `NotAuthenticated` in both cases and coerces it to 403 when there is no header to send, so assert the exact code your configuration produces rather than "401 or 403". ## Assertions worth adding, and ones to avoid - **Do** assert that the count of `ExpenseClaim` rows is unchanged after a rejected request. - **Do** assert server-computed fields (`owner`, `status`, timestamps set by the model) rather than echoing back the payload you sent. - **Do** remember that `response.data` holds serializer output: a `DecimalField` comes back as the string `"42.50"` because `COERCE_DECIMAL_TO_STRING` defaults to `True`. - **Avoid** comparing the entire `response.data` dict with a literal when it contains ids or timestamps; it makes the test fail on every unrelated field added later. - **Avoid** asserting only the status code — a 201 with the wrong owner is the bug the test exists to catch. ## Where the setup lines belong - **The user** — create it in `setUpTestData`, which runs once per test class rather than once per test method; Django's `TestCase` gives each test its own copy of class-level attributes such as `cls.user`, so one test changing the user does not leak into the next. - **The forced login** — call `force_authenticate()` in the test method or in `setUp`, not in `setUpTestData`: the test case builds a new `APIClient` before every test, so a forced user does not carry over from one test to the next. - **The URL** — resolve it with `reverse()` inside the test; a router renamed from `claims` to `expense-claims` then fails loudly at `reverse()` instead of silently hitting a 404. ## Where the shortcut stops `force_authenticate()` is the right default for business-logic tests because it keeps them independent of how clients log in. It does, however, **replace** the view's authentication classes for that request, so the token parsing, the session lookup and `SessionAuthentication`'s CSRF check never run. Keep a few separate tests that authenticate for real with `credentials()` or `login()`; the fast business-logic tests and the slower authentication tests answer different questions.

  • Why check the database row when the response already says 201?
    Because the response only proves the view returned success. The claim's `owner` should come from `request.user` in `perform_create()`; if the serializer exposes `owner` as writable and the view forgets to set it, the API can return 201 with a wrong or client-chosen owner. Loading the row and comparing `claim.owner` with the forced user catches exactly that bug.
  • The anonymous test expects 401 but gets 403. Is the endpoint broken?
    Not necessarily. DRF raises `NotAuthenticated` and then looks at the view's first authentication class: if it supplies a `WWW-Authenticate` header (Basic, Token) the response is 401, otherwise it is coerced to 403. With `SessionAuthentication` first, as in DRF's defaults, 403 is correct. Assert the code your configuration actually produces.
  • How do you undo force_authenticate halfway through a test?
    Call `self.client.force_authenticate(user=None)`. On `APIClient`, passing neither a user nor a token also calls `logout()`, which clears stored `credentials()` and any session, so the next request is anonymous.

saying these in an interview costs you the question

  • Asserting only the status code and never the created row or its owner
  • Assuming DRF rejects anonymous writes by default without IsAuthenticated
  • Expecting 401 for anonymous requests regardless of the authentication classes
  • Using Django's plain TestCase and then calling self.client.force_authenticate
  • Posting nested data without format='json' and blaming the serializer
open as a page

In Django REST Framework 3.18, what is the status of built-in OpenAPI schema generation, and what do new projects use instead?

level: middleimportance: must knowfreq 55%

basics

~10 s

DRF 3.18 still ships SchemaGenerator, AutoSchema, generateschema and get_schema_view, but its documentation deprecates them in favour of third-party packages and recommends drf-spectacular for OpenAPI 3. CoreAPI schema support was already removed in 3.17.0.

open as a page

In Django REST Framework, how does the browsable API differ from generated OpenAPI documentation, and why is it no substitute?

level: juniorimportance: should knowfreq 44%

basics

~20 s

The browsable API is an HTML renderer that shows one endpoint at a time, live, to a person in a browser acting as the current user. Generated OpenAPI documentation is a machine-readable description of every operation that partners and tools can use without your server.

open as a page

In Django REST Framework's APIClient, what does format='json' change, and what is sent without it?

level: middleimportance: should knowfreq 55%

basics

~10 s

format='json' makes APIClient render the body with JSONRenderer and send Content-Type application/json. Without it, DRF uses TEST_REQUEST_DEFAULT_FORMAT, which defaults to 'multipart', so nested dicts and None cannot be sent.

open as a page

In Django REST Framework, when do you test a view with APIRequestFactory instead of APIClient, and what must you do by hand?

level: middleimportance: should knowfreq 42%

basics

~20 s

APIRequestFactory only builds an HttpRequest; you call the view directly, so there is no URL routing or middleware. Use it to unit-test a view class; you must map viewset actions, pass URL kwargs, authenticate with force_authenticate(request, ...) and render the response yourself.

open as a page

In Django REST Framework tests, why assert on response.data rather than response.content, and what can response.data miss?

level: middleimportance: should knowfreq 38%

basics

~20 s

response.data is the Python data the view passed to Response, before rendering, so assertions are simpler than parsing bytes. It misses render-time changes, headers, and responses DRF never produced, such as Django's 404 for an unmatched URL.

open as a page

In Django REST Framework, why does a generated OpenAPI schema often misdescribe request and response bodies, and how do you correct it?

level: middleimportance: should knowfreq 38%

basics

~20 s

A DRF generator infers bodies from view.get_serializer(): one serializer for request and response, one success status, fallback types for unmappable fields. Correct it by declaring separate request and response serializers, error responses and explicit types, via AutoSchema overrides or a third-party generator's decorators.

open as a page

A Django REST Framework suite using force_authenticate everywhere is green, yet the browser app's POSTs get 403 CSRF failures; what did the shortcut hide?

level: seniorimportance: should knowfreq 35%

basics

~10 s

force_authenticate replaces the view's authentication classes with a forced one, so SessionAuthentication, which performs DRF's CSRF check, never ran. Add tests using APIClient(enforce_csrf_checks=True) with login(), and credentials() for token clients.

open as a page

Your team publishes a Django REST Framework API's OpenAPI schema to external partners; how do you keep that schema honest as the code changes?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Publish a generated, versioned schema file rather than a live, permission-filtered schema view; regenerate it in CI and fail on any diff or generator warning; exclude internal views, validate the document, and add error responses and auth the generator cannot infer.

open as a page