skip to content

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%

answer

  1. before the renderer runs
  2. serializer output, not model values
  3. errors are strings with codes
  4. not every response is DRF's

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.

solid answer

~40 s

`response.data` is what the view put into its `Response`, usually serializer output, so I can compare dicts instead of parsing `response.content`. The values are representations: a `DecimalField` is the string `'42.50'` under the default `COERCE_DECIMAL_TO_STRING`, validation errors are `ErrorDetail` strings that compare equal to plain text and carry a `.code` I can assert. What it cannot show is anything that happens later or elsewhere: a custom renderer that reshapes keys, headers like `Location`, and responses DRF never built — an unmatched URL returns Django's 404, which has no `.data` at all. So I assert status, selected keys and error codes on `response.data`, plus one `response.json()` or header check where rendering matters.

code

python · 18 lines
python
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase


class ClaimValidationTests(APITestCase):
    def setUp(self):
        user = get_user_model().objects.create_user(username="dana")
        self.client.force_authenticate(user=user)

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

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data)
        self.assertEqual(response.data["amount"], ["A valid number is required."])
        self.assertEqual(response.data["amount"][0].code, "invalid")
        self.assertEqual(response.json()["amount"], ["A valid number is required."])

go deeper

for a junior

Remember that response.data is the data the view returned, so you can compare it with a dict instead of decoding response.content yourself.

for a middle

Explain that response.data holds serializer representations, why Decimal comes back as a string, and how ErrorDetail equality and .code work.

for a senior

Name what data-level assertions skip — render-time transforms, headers, non-DRF responses — and add one wire-level check where the project renders non-trivially.

for a principal

Set the convention of asserting error codes rather than messages, so translated or reworded errors do not force a sweep through the test suite.

## What `response.data` is A DRF view returns `rest_framework.response.Response(data, status=...)`. The `data` argument — for a `ModelViewSet` action, the serializer's `.data` — is stored on the response as **`response.data`**, and the renderer turns it into bytes later. When `APIClient` makes the request, the object handed back to the test is that same `Response`, now rendered, so the test can read either: - **`response.data`** — the Python primitives the view produced, before rendering. - **`response.content`** — the bytes a client receives, after the renderer ran. DRF's own testing guide recommends asserting on `response.data`: `self.assertEqual(response.data, {"id": 4, "username": "lauren"})` is shorter and clearer than `json.loads(response.content)`, and it survives changes such as JSON whitespace settings (`COMPACT_JSON`) that do not change meaning. ## What the values in `response.data` look like `response.data` contains **serializer output**, which is already the representation, not model values: | Field | Model value | Value in `response.data` | |---|---|---| | `DecimalField` | `Decimal("42.50")` | `"42.50"` (a string, because `COERCE_DECIMAL_TO_STRING` defaults to `True`) | | `DateTimeField` | aware `datetime` | an ISO 8601 string (default `DATETIME_FORMAT`) | | `PrimaryKeyRelatedField` | a related instance | its primary key | | a validation error | — | a list of `ErrorDetail` objects | | a list endpoint | — | a `ReturnList`, or a dict with `results` when pagination is on | `ReturnDict` and `ReturnList` are `dict` and `list` subclasses, so comparing them with plain literals works. **`ErrorDetail`** is a `str` subclass with an extra `code`. Its equality compares the text and, only when the other side also has a `code`, the code too — so `response.data["amount"] == ["A valid number is required."]` is true. To pin the machine-readable reason rather than the translatable message, assert `response.data["amount"][0].code == "invalid"`. Two edge cases are worth knowing before they surprise you. A successful **delete** through `DestroyModelMixin` returns `Response(status=204)` with no data, so `response.data` is `None` and the assertion belongs on the status and on the row being gone. And because `response.data` is the object the view built, a view that returns a hand-made dict instead of serializer output is tested exactly as written — the test cannot tell whether a serializer was involved. ## What `response.data` cannot show you Asserting only on `response.data` leaves some layers untested: 1. **Anything changed at render time.** A custom renderer that transforms keys (for example to camelCase) or wraps the payload changes `response.content` but not `response.data`. If the project has one, at least one test should read `response.json()` — the Django test client's helper, which parses the body when the content type is JSON. 2. **Headers.** A create action sets `Location` when the serializer output has a `url` field; content type, caching and pagination headers live on the response, not in `.data`. 3. **Responses DRF did not produce.** A URL that matches no route returns Django's own 404 page, and a middleware can short-circuit with its own response. Neither is a DRF `Response`, so `response.data` raises `AttributeError`. A 404 raised *inside* a DRF view (`Http404`, `get_object_or_404`) is different: DRF's exception handler turns it into a `Response` whose data is `{"detail": "No ExpenseClaim matches the given query."}` or similar. ## Lists and pagination change the shape `DEFAULT_PAGINATION_CLASS` defaults to `None`, so a list endpoint returns a plain list and `len(response.data)` counts items. Once a project turns pagination on, for example with `PageNumberPagination`, `response.data` becomes a dict with `count`, `next`, `previous` and `results`: - `len(response.data)` then returns **4** — the number of keys — whatever the page holds, a classic false pass or false failure after pagination is switched on. - Assert on `response.data["results"]` for the items and on `response.data["count"]` for the total. - A test that must survive either configuration is testing the wrong thing; pin the pagination class the endpoint is meant to use. ## A robust order of assertions - **Status first**, with `response.data` as the failure message so a 400 prints its errors. - **Then selected keys** of `response.data`, not the whole dict when it contains ids and timestamps. - **Then error codes** via `ErrorDetail.code` for validation failures, so message rewording does not break tests. - **Then one wire-level check** with `response.json()` or headers if the project renders anything non-trivially. This keeps most tests at the data level, where they are readable, while one check per endpoint confirms the bytes a client parses.

  • Why can response.data hold '42.50' when the model stores Decimal('42.50')?
    Because `response.data` is serializer output, not the model instance. DRF's `DecimalField` returns a string by default so no precision is lost in JSON, controlled by `COERCE_DECIMAL_TO_STRING` (default `True`) or the field's `coerce_to_string` argument. Compare with the string, or read the row from the database to compare `Decimal` values.
  • When is response.json() the better assertion target?
    When the rendered body differs from the data: a custom renderer that renames keys or wraps payloads, or a response DRF did not build but that is still JSON. `response.json()` comes from Django's test client, parses the body and raises `ValueError` if the content type is not JSON, which is itself a useful check.

saying these in an interview costs you the question

  • Expecting response.data to hold Decimal or datetime objects from the model
  • Believing ErrorDetail objects never compare equal to plain strings
  • Assuming every response in a DRF project has a .data attribute
  • Asserting error message text only, then breaking on every rewording
  • Thinking response.data proves what a custom renderer sends on the wire