Filter concepts by levelShowing all levels.

Django · API Testing

Django API Testing

Authentication and authorization, the contract surface, and what a request actually did

  • Test the list route separatelyhas_object_permission never runs for a collection — only get_queryset() protects it
  • force_authenticate vs credentials()the first is for authorization tests, the second for authentication tests
  • Assert the exact status code201 quietly becoming 200 is a breaking change no body assertion catches
  • Assert the error shape, not just the codeclients branch on the type string and on which field is named
  • After a failure, assert the ABSENCE of side effectsan unrolled-back transaction still returns a correct error code

1.Testing authentication, authorization, and permission failures

DRF ships `APIClient`, which is Django's test client plus content-type handling and three ways to authenticate: `force_authenticate(user)` on a request factory, `client.force_authenticate(user)` to skip credential checking entirely, and `client.credentials(HTTP_AUTHORIZATION=...)` to send a real header when the scheme itself is what you are testing. The tests that matter most are the negative ones. For every protected endpoint, assert the anonymous case (401 or 403 — and which one depends on the first authentication class), the wrong-user case, and the wrong-role case. And test the **list** endpoint separately from the detail endpoint, because DRF never calls `has_object_permission()` for a list, so a permission class that looks correct can still return every row in the table.

Key features

  • `APIClient` is Django's test client with content-type handling: `client.post(url, data, format="json")` rather than manual encoding
  • `client.force_authenticate(user)` bypasses the whole authentication layer — right for authorization tests, wrong for testing the scheme itself
  • `client.credentials(HTTP_AUTHORIZATION="Bearer …")` sends a real header, which is what actually exercises the authentication classes
  • Anonymous denial is 401 or 403 depending on whether the *first* authentication class can supply a `WWW-Authenticate` header — assert the one your stack produces
  • Test the list endpoint separately: DRF never calls `has_object_permission()` for `list`, so scoping there is `get_queryset()`'s job alone
  • `client.force_authenticate(user=None)` resets to anonymous — necessary when one test method makes several calls as different users

The matrix every protected endpoint needs

The matrix every protected endpoint needs
CallerExpectedWhat it catches
anonymous401 or 403a missing `permission_classes` entirely
authenticated, not the owner (detail)403 or 404a missing `has_object_permission`
authenticated, not the owner (list)200 with the row **absent**an unscoped `get_queryset()` — the real leak
authenticated, wrong role403a role check that was never wired up
authenticated, allowed200 / 201the happy path
Where each denial comes from — and the cell most suites never test
401 / 403 from permission_classes
has_permission() runs before anything is fetched
401 / 403 — same class, same check
the collection is denied the same way when unauthenticated
403 or 404 from has_object_permission()
runs from get_object(); 404 if you filtered the queryset instead
200 with the row leaked
has_object_permission is NEVER called for list — only get_queryset() protects this
  • 401 / 403 from permission_classes: detail route (/orders/57/), anonymous — has_permission() runs before anything is fetched
  • 401 / 403 — same class, same check: collection route (/orders/), anonymous — the collection is denied the same way when unauthenticated
  • 403 or 404 from has_object_permission(): detail route (/orders/57/), authenticated, not yours — runs from get_object(); 404 if you filtered the queryset instead
  • 200 with the row leaked: collection route (/orders/), authenticated, not yours — has_object_permission is NEVER called for list — only get_queryset() protects this

Command

python
client = APIClient()
client.force_authenticate(user)                       # authorization tests
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")   # authentication tests

Use `force_authenticate` for authorization tests and real `credentials()` headers for authentication tests — mixing them means a token bug hides behind a helper that never checks tokens. Give every protected endpoint the four-row matrix (anonymous, not-yours, wrong-role, allowed), and parameterize it so it is affordable everywhere. Above all, test the list endpoint separately: DRF never calls `has_object_permission()` for a collection, so the leak that matters is only visible in a test that asserts a specific foreign row is absent.

2.Testing validation, error schemas, status codes, and collections

An API's contract is its status codes, its response shape and its error shape, and all three are things clients hard-code — so all three deserve tests. Assert the **status code** explicitly on every path, because 200-instead-of-201 or 400-instead-of-409 is a breaking change no functional assertion catches. Assert the **error schema**: not just that a 400 happened, but that the body carries the expected `type` and the expected per-field keys, since that is what client error handling branches on. And test the collection controls as behaviour rather than as configuration — that `?status=paid` actually excludes unpaid rows, that page 2 does not repeat page 1, and that `?ordering=` is refused for a field you never allowed.

Key features

  • Assert the status code explicitly on every path — 201 versus 200 and 400 versus 409 are breaking changes a body assertion will not catch
  • Assert the error *shape*, not just the code: the `type`/`code` string and the per-field keys are what clients branch on
  • Test validation through the endpoint for the fields that matter, and directly on the serializer for the exhaustive rule table — one is a contract, the other is logic
  • Test filters by behaviour (`?status=paid` returns exactly the paid rows), never by asserting on `filterset_fields`
  • For pagination, assert that page 2 shares no ids with page 1 — a count-per-page check misses the duplicates an unstable sort produces
  • Assert that `?ordering=` on a field outside `ordering_fields` is ignored or refused, since the default is to allow any serializer-readable field

What to assert, per contract

What to assert, per contract
ContractAssertBreaks silently without it
Status codesthe exact code on success and each failure201 quietly becoming 200
Response shapethe keys clients read, and their typesa renamed or retyped field
Error shape`type`/`code` plus per-field keysa reshaped envelope breaking every client at once
Validationwhich field is reported, not just that it failedan error attributed to the wrong field
Paginationpage 2 shares no ids with page 1an unstable sort duplicating rows
Filteringthe excluded rows are actually absenta filter silently ignored
Orderinga disallowed field is refusedordering by a field the API never exposes
Four contracts, four kinds of assertion

1 · Status codes, on every path

The cheapest assertion in the suite, and the one that catches a breaking change no body check would notice.

2 · The error envelope, not just the code

Clients branch on the stable type string and on which field is named. Asserting only the status leaves both free to change.

3 · Collections, by behaviour

Assert on which rows came back, not on which settings exist — the test then survives a move from filterset_fields to a FilterSet class.

4 · Pagination, with a negative assertion

Overlap between pages is what an unstable sort produces, and a per-page count check cannot see it.

  1. 1 · Status codes, on every path — The cheapest assertion in the suite, and the one that catches a breaking change no body check would notice.
  2. 2 · The error envelope, not just the code — Clients branch on the stable type string and on which field is named. Asserting only the status leaves both free to change.
  3. 3 · Collections, by behaviour — Assert on which rows came back, not on which settings exist — the test then survives a move from filterset_fields to a FilterSet class.
  4. 4 · Pagination, with a negative assertion — Overlap between pages is what an unstable sort produces, and a per-page count check cannot see it.

Command

python
response = api.post(url, {"quantity": -1}, format="json")
assert response.status_code == 400
assert set(response.json()["errors"]) == {"quantity"}

Status codes, response shape and error shape are the parts clients hard-code, so pin all three — an explicit `== 201` is what catches a "tidy-up" that turns it into a 200, and asserting the error `type` plus the per-field keys is what stops a reshaped envelope shipping silently. Test collection controls as behaviour, never as configuration: assert that a filtered-out row is *absent*, that page 2 shares no ids with page 1, and that ordering on a field you never allowed is refused.

3.Testing idempotency, rate limits, side effects, and integrations

The last group is about what a request *did*, not what it returned. **Database side effects**: after a successful call, assert the rows — created, updated, and importantly *not* created on the failure path, since a half-applied write is the bug a status-code assertion cannot see. **Idempotency**: send the same request twice with the same key and assert the second returns the stored response and creates nothing new. **Rate limiting**: exhaust the limit and assert 429 plus `Retry-After` — and remember `LocMemCache` makes this testable in a way production is not. **External integrations**: test both directions, the success and the failure, because the failure path is the one that ships untested and runs during an incident.

Key features

  • After a failure, assert the *absence* of side effects — a rolled-back transaction is invisible to a status-code check
  • For idempotency, assert `count()` is unchanged across the second call; the status code alone cannot distinguish a replay from a second creation
  • Rate-limit tests need the cache cleared between tests (`cache.clear()` in a fixture), or the previous test's counter leaks in
  • Assert `Retry-After` on the 429, since that is what a client actually reads to decide when to retry
  • Drive every integration both ways — success and failure — because the failure branch is what runs during an incident and is usually untested
  • Use `django_capture_on_commit_callbacks(execute=True)` so `on_commit` side effects (emails, enqueued tasks) actually happen inside the test

The assertion each side-effect test actually needs

The assertion each side-effect test actually needs
BehaviourWeak assertionThe one that catches the bug
Successful create`status_code == 201`the row exists, with the right values
Failed create`status_code == 400`no partial rows exist
Idempotent replay`status_code == 201` twice`Order.objects.count()` unchanged
Rate limit`status_code == 429`429 **and** a `Retry-After` header
Provider succeedsthe mock was calledthe order is `paid` and a `Payment` row exists
Provider times outa 502 was returnedthe order is still `draft` and no `Payment` row exists
One idempotent endpoint, tested through both a replay and a provider failure
Test
API
Database
Mocked provider
  1. 1POST /payments/ · Idempotency-Key: k1
  2. 2charge()patched at the boundary, returns ch_1
  3. 3INSERT key k1 + INSERT payment, one transaction
  4. 4201 · assert Payment.objects.count() == 1
  5. 5POST again · SAME key k1the replay case
  6. 6INSERT key k1 → IntegrityError
  7. 7201, stored body · assert count() STILL 1the count is the assertion; the status alone proves nothing
  8. 8POST /payments/ · key k2
  9. 9charge() → Timeout
  10. 10502 · assert count() STILL 1 and order is draftthe negative assertion no status check can make
  1. Test → API: POST /payments/ · Idempotency-Key: k1
  2. API → Mocked provider: charge() (patched at the boundary, returns ch_1)
  3. API → Database: INSERT key k1 + INSERT payment, one transaction
  4. API → Test: 201 · assert Payment.objects.count() == 1
  5. Test → API: POST again · SAME key k1 (the replay case)
  6. API → Database: INSERT key k1 → IntegrityError
  7. API → Test: 201, stored body · assert count() STILL 1 (the count is the assertion; the status alone proves nothing)
  8. Test → API: POST /payments/ · key k2
  9. API → Mocked provider: charge() → Timeout
  10. API → Test: 502 · assert count() STILL 1 and order is draft (the negative assertion no status check can make)

Command

python
before = Payment.objects.count()
api.post(url, payload, format="json", HTTP_IDEMPOTENCY_KEY="k1")
api.post(url, payload, format="json", HTTP_IDEMPOTENCY_KEY="k1")
assert Payment.objects.count() == before + 1

These tests are about what a request *did*. After a success, assert the rows and the `on_commit` effects — wrap the call in `django_capture_on_commit_callbacks(execute=True)` or they never fire. After a failure, assert the *absence* of side effects, because an unrolled-back transaction returns a perfectly correct error code. For idempotency the assertion is `count()`, not the status. For rate limits, clear the cache between tests and assert `Retry-After`. And drive every integration both ways: the failure branch is the one that runs during an incident.

Remember

  • Every protected endpoint has a four-row matrix — anonymous, not-yours, wrong-role, allowed — and three of the four rows are failures.
  • Test filters by behaviour and include the negative half: an ignored filter and a filter that matched everything are identical from a positive assertion alone.
  • For pagination, assert page 2 shares no ids with page 1 — a per-page count check cannot see the duplicates an unstable sort produces.
  • For idempotency the assertion is count(), not the status code: a replay and a second creation both return 201.

Pro tip

  • Clear the cache in an autouse fixture: throttle counters live there and are not rolled back the way the database is, so they leak between tests.
  • Wrap the call in django_capture_on_commit_callbacks(execute=True), or the emails and enqueued tasks you are asserting on never fire.

Next up

  • Database Testing
  • Contract and Integration Testing
  • Performance Fundamentals

Sources

  • DRF Testing — django-rest-framework.org/api-guide/testing/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.