Filter concepts by levelShowing all levels.

Django · Section 68

Django API Testing

Level
advanced
Read
30 min
Concepts
3

DRF's `APIClient` is Django's test client with content-type handling and three ways to authenticate, and the choice between them matters: `force_authenticate` bypasses the authentication layer entirely, which is right for authorization tests and wrong for testing the scheme itself, while `credentials(HTTP_AUTHORIZATION=…)` sends a real header and actually exercises the authentication classes. Every protected endpoint deserves the same four-row matrix — anonymous, authenticated-but-not-yours, wrong-role, allowed — of which three rows are failures, which is exactly why they are the rows most suites lack. And the detail route must be tested separately from the list, because DRF never calls `has_object_permission()` for a collection: a permission class that correctly returns 404 for someone else's order can sit beside a list endpoint returning the whole table, and no amount of detail-route testing will reveal it. The second group is the contract — status codes, response shape and error shape are what clients hard-code, so an explicit `== 201` catches the tidy-up that turns it into a 200, and asserting the error `type` plus its per-field keys is what stops a reshaped envelope shipping silently. Collection controls are tested as behaviour rather than configuration: a filter must be shown to *exclude*, pages must be shown not to overlap, and ordering on a field outside `ordering_fields` must be refused. The third group is what the request actually did. After a success, assert the rows and the `on_commit` effects; after a failure, assert their absence, because an unrolled-back transaction returns a perfectly correct error code. Idempotency is a `count()` assertion, rate limiting needs the cache cleared between tests and a `Retry-After` check, and every integration is driven both ways — since the failure branch is the one that runs during an incident.

What is true here

  1. force_authenticate for authorization tests, real credentials() for authentication tests.
  2. The list route needs its own test — has_object_permission() never runs for a collection.
  3. Pin status codes and error shapes explicitly; they are the parts clients hard-code.
  4. Test filters, pagination and ordering by behaviour, including the negative assertions.
  5. After a failure, assert the absence of side effects — the status code cannot reveal a partial write.

What you will be able to do

  • Cover the full authorization matrix for an endpoint without writing four times the tests
  • Catch a list endpoint that leaks rows a detail test would never expose
  • Pin an API contract so a breaking change fails a test rather than a client
  • Test idempotency, rate limiting and integration failure paths by asserting on state
One API request, and the four things a test can check about it
invalidproviderfailsand still assertthe error shape

api.post(url, payload, format="json")

Authentication

credentials() exercises it; force_authenticate skips it

Authorization

the four-row matrix — and the list route separately

Throttling

clear the cache between tests, then assert 429 + Retry-After

Serializer validation

assert WHICH field is named, not just that it failed

The view runs

Contract assertions

exact status code · response keys and types · error type + per-field keys

Collection assertions

the excluded row is absent · page 2 shares no ids with page 1

Side-effect assertions

rows created · on_commit fired · count() unchanged on replay

Failure path

provider timeout, validation error, conflict

Assert the ABSENCE of side effects

the assertion an unrolled-back transaction cannot survive

  • api.post(url, payload, format="json")
    • leads to Authentication
  • Authentication — credentials() exercises it; force_authenticate skips it
    • leads to Authorization
  • Authorization — the four-row matrix — and the list route separately
    • leads to Throttling
  • Throttling — clear the cache between tests, then assert 429 + Retry-After
    • leads to Serializer validation
  • Serializer validation — assert WHICH field is named, not just that it failed
    • leads to The view runs
    • on error, leads to Failure path (invalid)
  • The view runs
    • leads to Contract assertions
    • leads to Collection assertions
    • leads to Side-effect assertions
    • on error, leads to Failure path (provider fails)
  • Contract assertions — exact status code · response keys and types · error type + per-field keys
  • Collection assertions — the excluded row is absent · page 2 shares no ids with page 1
  • Side-effect assertions — rows created · on_commit fired · count() unchanged on replay
  • Failure path — provider timeout, validation error, conflict
    • on error, leads to Assert the ABSENCE of side effects
    • leads to Contract assertions (and still assert the error shape)
  • Assert the ABSENCE of side effects — the assertion an unrolled-back transaction cannot survive

Authentication and authorization

The four-row matrix, the two ways to authenticate a test client, and the list-versus-detail gap.

Testing authentication, authorization, and permission failures

coreintermediate

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.

Think of it as

Authorization tests are the ones a suite most often lacks, because the happy path is what gets written first and the negative paths only exist if someone deliberately writes them. The useful discipline is to treat every protected endpoint as having a small fixed matrix — anonymous, authenticated-but-not-yours, authenticated-with-the-wrong-role, authenticated-and-allowed — and to notice that three of those four rows are failures. Parameterizing that matrix is what makes it affordable to have on every endpoint rather than on the two people remembered. The other half is where the leak actually happens. Object-level permissions only run when `get_object()` runs, so `GET /orders/57/` on someone else's order is protected by a permission class while `GET /orders/` is protected only by `get_queryset()` — two different mechanisms, and a test of the first proves nothing about the second. That is why the list-scoping test deserves its own name and its own assertion: create data belonging to another user, request the collection, and assert it is absent. Finally, prefer `force_authenticate` for tests about *authorization* and real credentials for tests about *authentication* — mixing them means a token bug hides behind a helper that never checks tokens.

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

What we're doing: Cover the whole matrix with one parametrized test, plus the list-scoping test that the matrix cannot express.

orders/tests/test_api_permissions.pypython
@pytest.fixture
def api():
    return APIClient()


@pytest.mark.django_db
@pytest.mark.parametrize("who,expected", [
    ("anonymous", 401),      # our first auth class is JWT, so a challenge exists
    ("other", 404),          # get_queryset() scopes, so it is absent, not forbidden
    ("support", 200),
    ("owner", 200),
])
def test_detail_access(api, order, users, who, expected):
    if who != "anonymous":
        api.force_authenticate(users[who])
    assert api.get(reverse("order-detail", args=[order.pk])).status_code == expected


@pytest.mark.django_db
def test_list_does_not_leak_other_customers_orders(api, order, users):
    other = OrderFactory(customer=users["other"])
    api.force_authenticate(order.customer)

    body = api.get(reverse("order-list")).json()
    ids = [row["id"] for row in body["results"]]

    assert order.id in ids
    assert other.id not in ids        # the assertion no detail test can make


@pytest.mark.django_db
def test_expired_token_is_rejected(api, expired_token):
    api.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
    response = api.get(reverse("order-list"))
    assert response.status_code == 401
    assert response.json()["type"] == "token_not_valid"


@pytest.mark.django_db
def test_refund_requires_the_billing_role(api, order, users):
    api.force_authenticate(users["support"])          # can read, cannot refund
    response = api.post(reverse("order-refund", args=[order.pk]), {}, format="json")
    assert response.status_code == 403
8
Asserting 401 rather than 403 is a claim about the stack: DRF only returns 401 when the first authentication class can supply a `WWW-Authenticate` header. Pin whichever your project actually produces.
9
404 rather than 403 for another customer's order, because the queryset is filtered — the response deliberately cannot be used to confirm the order exists.
22–28
The test that the parametrized matrix cannot express. Object-level permissions never run for a list, so this asserts on `get_queryset()` and nothing else.
32–33
Real credentials, not `force_authenticate` — this test is about the authentication scheme, and the helper would bypass exactly the code under test.
40–43
Role separation: a user who legitimately passes every earlier check must still be refused this one action.

Why this works: The matrix covers who may reach an object, the list test covers what a collection returns, and the token test covers the scheme itself — three different mechanisms that a single happy-path test would leave entirely unverified.

Testing only the detail route and assuming the list is covered

Wrong

python
def test_cannot_read_someone_elses_order(api, users):
    other = OrderFactory(customer=users["other"])
    api.force_authenticate(users["owner"])
    assert api.get(reverse("order-detail", args=[other.pk])).status_code == 404
# Green. GET /orders/ still returns every order in the table.

Better

python
def test_list_is_scoped(api, users):
    other = OrderFactory(customer=users["other"])
    api.force_authenticate(users["owner"])
    ids = [r["id"] for r in api.get(reverse("order-list")).json()["results"]]
    assert other.id not in ids

What you see: Every permission test passes, and the collection endpoint returns other customers' orders. It is found by a customer noticing unfamiliar data, not by the suite.

Why: DRF calls `has_object_permission()` from `get_object()`, which a list response never invokes — so the detail route and the list route are protected by two different mechanisms. A permission class implementing only the object-level hook secures the first and does nothing for the second, and no amount of detail-route testing can detect that. The list needs its own test asserting a specific foreign row is absent.

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

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

Together

python
client.force_authenticate(other_user)
response = client.get(reverse("order-list"))
assert order.id not in [row["id"] for row in response.json()["results"]]

Remember: 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.

See also: testing contracts status codes and collections · custom basepermission and object level checks · authentication order anonymous users and failures

Advertisement

The contract surface

Status codes, response and error shapes, and testing collection controls as behaviour.

Testing validation, error schemas, status codes, and collections

coreintermediate

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.

Think of it as

Think of these tests as pinning the parts of the response that are not yours to change unilaterally. A view's internals can be rewritten freely; its status codes and body shape cannot, because somewhere a client is switching on them. That makes an explicit `assert response.status_code == 201` valuable even when it feels redundant — it is the line that fails when someone "tidies up" a view and quietly turns a 201 into a 200. Error schemas deserve the same treatment and usually get none: teams assert `status_code == 400` and never check the body, so a change in the error envelope ships silently and breaks every client's error path at once. The collection controls are the other place where testing configuration rather than behaviour is tempting. Asserting that `filterset_fields` contains `"status"` proves the setting exists; asserting that `?status=paid` returns exactly the paid rows proves the filter works, and it keeps working if the implementation moves to a `FilterSet` class. Pagination is worth a specifically negative assertion — that page 2 contains none of page 1 — because an unstable sort produces duplicates that a per-page count check would never notice.

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

What we're doing: Pin the whole contract for one endpoint: codes, shapes, validation attribution, and every collection control.

orders/tests/test_api_contract.pypython
@pytest.mark.django_db
def test_create_returns_201_and_the_documented_shape(api, user):
    api.force_authenticate(user)
    response = api.post(reverse("order-list"),
                        {"items": [{"sku": "X", "quantity": 2}]}, format="json")

    assert response.status_code == 201
    body = response.json()
    assert set(body) >= {"id", "status", "total", "created_at"}
    assert body["status"] == "draft"
    assert isinstance(body["total"], str)          # Decimal is serialized as a string


@pytest.mark.django_db
@pytest.mark.parametrize("payload,field", [
    ({"items": [{"sku": "X", "quantity": -1}]}, "items"),
    ({"items": []}, "items"),
    ({}, "items"),
])
def test_validation_errors_name_the_right_field(api, user, payload, field):
    api.force_authenticate(user)
    response = api.post(reverse("order-list"), payload, format="json")

    assert response.status_code == 400
    assert response.json()["type"] == "validation_error"
    assert field in response.json()["errors"]


@pytest.mark.django_db
def test_filter_excludes_as_well_as_includes(api, user):
    paid = OrderFactory(customer=user, status="paid")
    draft = OrderFactory(customer=user, status="draft")
    api.force_authenticate(user)

    ids = [r["id"] for r in api.get(reverse("order-list"), {"status": "paid"}).json()["results"]]
    assert paid.id in ids
    assert draft.id not in ids


@pytest.mark.django_db
def test_pages_do_not_overlap(api, user):
    OrderFactory.create_batch(75, customer=user)
    api.force_authenticate(user)

    p1 = {r["id"] for r in api.get(reverse("order-list"), {"page": 1}).json()["results"]}
    p2 = {r["id"] for r in api.get(reverse("order-list"), {"page": 2}).json()["results"]}
    assert not (p1 & p2)


@pytest.mark.django_db
def test_ordering_is_restricted(api, user):
    api.force_authenticate(user)
    response = api.get(reverse("order-list"), {"ordering": "customer__password"})
    assert response.status_code in (200, 400)
    # Whichever the project chose, the field must not have been used:
    assert "password" not in response.content.decode()
7–11
Status code, the key set, and the *type* of `total`. Decimal serializing as a string rather than a float is exactly the kind of contract detail a client hard-codes.
15–19
Three invalid payloads, each asserting which field is named. An error attributed to the wrong field is a real bug that a bare `== 400` never sees.
36–37
Both halves: the paid order is present *and* the draft is absent. Omitting the second assertion passes even when the filter is ignored entirely.
45–47
A set intersection is the assertion that catches an unstable sort. Counting rows per page would not.
53–56
Ordering by a field outside `ordering_fields` — the DRF default allows any serializer-readable field, so this pins that the project restricted it.

Why this works: Each assertion pins something a client depends on and a refactor could change without any functional test noticing — which is the entire job of a contract test.

Asserting only that a request failed

Wrong

python
response = api.post(url, {"items": []}, format="json")
assert response.status_code == 400

Better

python
response = api.post(url, {"items": []}, format="json")
assert response.status_code == 400
assert response.json()["type"] == "validation_error"
assert "items" in response.json()["errors"]

What you see: The error moves to a different field, or the envelope changes from `errors` to `detail`, and every client's form-highlighting breaks — while the test suite stays entirely green.

Why: A bare status assertion says only that *something* was rejected, which is true for a wide range of wrong behaviours: the right rejection attributed to the wrong field, a completely different validation error, or a reshaped envelope. The parts clients actually consume are the stable `type` string and the per-field keys, so those are the parts worth pinning.

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.

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

Together

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

Remember: 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.

See also: testing authentication and authorization · testing side effects idempotency and integrations · global exception handlers and stable error payloads · cursor pagination and stable ordering

Advertisement

What the request actually did

Side effects, idempotency, rate limits, and driving every integration both ways.

Testing idempotency, rate limits, side effects, and integrations

coreadvanced

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.

Think of it as

The section says to test both the happy path and the failure path, and the failure path is where side-effect testing earns its keep. A 400 or a 502 tells you the request was rejected; it does not tell you whether the order row was rolled back, whether the payment row was left orphaned, or whether the confirmation email went out anyway. Those are exactly the bugs that surface as customer complaints rather than as exceptions, so the assertion that matters after a failure is usually a *negative* one about the database: `assert not Payment.objects.filter(order=order).exists()`. Idempotency has the same shape — the second request must not merely succeed, it must not create anything, so `count()` before and after is the real assertion and the status code is secondary. Rate limiting is the one area where the test environment is genuinely more capable than production: `LocMemCache` is per process, and in a single-process test run that means the counter behaves exactly as configured, which is the only place you can assert the limit precisely. And for integrations, mock at the boundary and drive it both ways — one test where the provider succeeds, one where it times out — because a retry policy or a rollback that has never been executed in a test is a guess.

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

What we're doing: Both directions of one integration, a replay, and a rate limit — each asserting on state rather than on the status code alone.

payments/tests/test_api_side_effects.pypython
@pytest.fixture(autouse=True)
def clear_throttle_cache():
    cache.clear()          # otherwise the previous test's counter leaks into this one
    yield
    cache.clear()


@pytest.mark.django_db
def test_charge_succeeds_and_records_the_payment(api, order, user,
                                                 django_capture_on_commit_callbacks):
    api.force_authenticate(user)
    with patch("payments.services.charge_card", autospec=True) as charge:
        charge.return_value = Charge(id="ch_1", status="succeeded")
        with django_capture_on_commit_callbacks(execute=True):
            response = api.post(reverse("payment-list"), {"order": order.pk},
                                format="json", HTTP_IDEMPOTENCY_KEY="k1")

    assert response.status_code == 201
    order.refresh_from_db()
    assert order.status == "paid"
    assert Payment.objects.filter(order=order, charge_id="ch_1").count() == 1
    assert len(mail.outbox) == 1          # the on_commit side effect really fired


@pytest.mark.django_db
def test_provider_timeout_leaves_nothing_behind(api, order, user):
    api.force_authenticate(user)
    with patch("payments.services.charge_card", autospec=True) as charge:
        charge.side_effect = requests.Timeout()
        response = api.post(reverse("payment-list"), {"order": order.pk},
                            format="json", HTTP_IDEMPOTENCY_KEY="k2")

    assert response.status_code == 502
    order.refresh_from_db()
    assert order.status == "draft"                              # rolled back
    assert not Payment.objects.filter(order=order).exists()     # nothing orphaned
    assert mail.outbox == []                                    # no receipt sent


@pytest.mark.django_db
def test_replay_with_the_same_key_creates_nothing_new(api, order, user):
    api.force_authenticate(user)
    with patch("payments.services.charge_card", autospec=True) as charge:
        charge.return_value = Charge(id="ch_1", status="succeeded")
        first = api.post(reverse("payment-list"), {"order": order.pk},
                         format="json", HTTP_IDEMPOTENCY_KEY="k3")
        second = api.post(reverse("payment-list"), {"order": order.pk},
                          format="json", HTTP_IDEMPOTENCY_KEY="k3")

    assert first.status_code == second.status_code == 201
    assert first.json() == second.json()          # the STORED response, replayed
    assert Payment.objects.filter(order=order).count() == 1
    assert charge.call_count == 1                 # the provider was called once


@pytest.mark.django_db
def test_rate_limit_returns_429_with_retry_after(api, user, settings):
    settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK,
                               "DEFAULT_THROTTLE_RATES": {"payments": "2/min"}}
    api.force_authenticate(user)

    for _ in range(2):
        api.post(reverse("payment-list"), {}, format="json")
    response = api.post(reverse("payment-list"), {}, format="json")

    assert response.status_code == 429
    assert int(response["Retry-After"]) > 0
1–5
An autouse fixture clearing the cache. Throttle counters live there, so without this the second rate-limit test inherits the first one's count and fails unpredictably.
14–22
The happy path asserts four things, only one of which is the status code: the order moved, exactly one payment row exists, and the `on_commit` email actually fired.
34–37
The failure path, and every assertion is negative. This is the test that catches a transaction that did not roll back — nothing about the 502 itself would reveal it.
50–52
Three assertions for a replay: the same stored body, one row, and one provider call. The status code alone cannot distinguish a replay from a second charge.
58–67
Overriding the rate in `settings` keeps the test fast, and asserting `Retry-After` covers what a client actually reads to decide when to try again.

Why this works: Every one of these bugs — an unrolled-back transaction, a double charge, a missing `Retry-After` — returns a perfectly reasonable status code, so the assertions that catch them are all about state.

Testing only that a failure returned a failure code

Wrong

python
charge.side_effect = requests.Timeout()
response = api.post(url, payload, format="json")
assert response.status_code == 502

Better

python
charge.side_effect = requests.Timeout()
response = api.post(url, payload, format="json")
assert response.status_code == 502
order.refresh_from_db()
assert order.status == "draft"
assert not Payment.objects.filter(order=order).exists()

What you see: A provider timeout leaves an order marked `paid` with no payment behind it, or a `Payment` row with no charge. The API returned 502 exactly as designed, so nothing in the test suite or the logs suggests anything is wrong.

Why: A status code describes the response, not the database. If the write happened outside the `atomic()` block, or the exception was caught and swallowed after a partial save, the response is still a correct 502 and the data is still wrong. Only an assertion about rows can tell those apart — and after a failure, the useful assertion is almost always that nothing exists.

One idempotent endpoint, tested through both a replay and a provider failure
Test
API
Database
Mocked provider
  1. 1. POST /payments/ · Idempotency-Key: k1
  2. 2. charge()patched at the boundary, returns ch_1
  3. 3. INSERT key k1 + INSERT payment, one transaction
  4. 4. 201 · assert Payment.objects.count() == 1
  5. 5. POST again · SAME key k1the replay case
  6. 6. INSERT key k1 → IntegrityError
  7. 7. 201, stored body · assert count() STILL 1the count is the assertion; the status alone proves nothing
  8. 8. POST /payments/ · key k2
  9. 9. charge() → Timeout
  10. 10. 502 · 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)

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

Together

python
assert response.status_code == 502
order.refresh_from_db()
assert order.status == "draft"
assert not Payment.objects.filter(order=order).exists()   # the real assertion

Remember: 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.

See also: testing contracts status codes and collections · testing authentication and authorization · idempotency keys and http semantics · what to mock and what not to

Advertisement