skip to content

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