Asserting Success: Status Code Plus Payload Shape, Not Either
A passing test asserts pm.response.to.have.status(200) and moves on. Six weeks later a mobile client crashes because the server started returning customer: null where an object used to be — with a cheerful 200 the whole time. A status code tells you the server accepted the request; it says nothing about whether it answered the question your consumer actually asks. The success contract of every endpoint has two axes — code and shape — and a test that checks only one of them is documenting half a promise.
Start with the codes themselves, because 200, 201, and 204 are not interchangeable. 200 OK carries a body that must match the endpoint's representation. 201 Created must tell the client where the new resource lives: the Location: /orders/42 header is required by RFC 9110, and a create endpoint that omits it has failed the contract even if the body looks perfect — your test should assert the header and, better, follow it: GET the Location value and expect 200 with the same id. 204 No Content is the opposite trap: there is no body by definition, so a Postman script that does pm.response.json() on a 204 throws a parse error that reads like an API bug but is a test bug. Assert pm.response.to.be.empty and stop.
Then the shape. For each endpoint write the expected representation down — required fields, their types, and constrained values — and assert exactly that:
pm.test("order payload matches contract", function () {
const o = pm.response.json();
pm.expect(o).to.include.keys("id", "status", "total", "createdAt");
pm.expect(o.id, "id is a positive integer").to.be.a("number").that.is.above(0);
pm.expect(o.status, "status is a known enum").to.be.oneOf(["open", "shipped", "cancelled"]);
pm.expect(o.createdAt, "createdAt is ISO-8601").to.match(/^\d{4}-\d{2}-\d{2}T/);
pm.expect(o.total, "total is never negative").to.be.at.least(0);
});
Three habits separate a useful shape assertion from a brittle one. Assert presence and type, not exact values, for server-generated fields — timestamps and ids change per run, and hard-coded equality is tomorrow's flaky test. Assert enums exhaustively where the consumer branches on them: a new status value the app does not know is a breaking change, and only a oneOf assertion catches it. And for list endpoints, assert the envelope: pagination fields (total, nextCursor), that items is an array whose entries satisfy the single-item contract, and that limit echoes what you requested.
One endpoint per resource usually deserves a compact contract table in its description, which doubles as your test checklist:
| Endpoint | Success | Must contain | Headers |
|---|---|---|---|
POST /orders |
201 | body with server-assigned id |
Location |
GET /orders/:id |
200 | full representation | cache-control |
PATCH /orders/:id |
200 | updated fields | — |
DELETE /orders/:id |
204 | none | — |
Beyond presence and type, the third layer of a success assertion is relational: the response must agree with the world your requests created. If POST /orders said 201 with total: 49.99, then GET /orders/42 must return that same total — cross-request consistency catches "create accepted but persisted wrong" bugs that single-request shape checks cannot see. For read-after-write tests, keep the writes inside the test's own flow (create, then fetch the Location) so the suite never depends on data seeded by whoever ran last. That habit also keeps your success contract honest: a representation that cannot be re-read is not a representation, it is a receipt.
Headers close the contract. content-type: application/json on a JSON API (a text/html body usually means an error page sneaked through with a 200 — a classic proxy misconfiguration) and cache-control where the endpoint documents caching. One caveat to carry into automation: assert only what the contract promises. Pinning incidental headers like Date or X-Powered-By turns harmless infrastructure changes into red builds, and a suite that cries wolf stops being read.
Interviewers probe exactly here — "how do you know an API response is correct?" The answer that lands: status code first, then structural assertions per the documented contract, then relational checks (the Location you follow resolves; the created resource appears in the list). The dark side of the contract — the failure paths a well-designed API must produce on purpose — is where we go next: Automating Postman Collections builds the scripts you just saw, but first we break things deliberately.
Probing the Failure Surface: 400, 403, 404, and the 500 That Must Not Happen
Happy-path tests grade an API on what it does when everything is right. The tests that earn their keep grade it on what it does when everything is wrong — and "fails safely and informatively" is a testable property, not a compliment. For each endpoint, build a negative matrix: one row per input class that can go wrong, one column per expected outcome.
The matrix for a typical POST /orders looks like this:
| Input fault | Expected | What the assertion checks |
|---|---|---|
Body not JSON ({broken) |
400 | parse error named, no stack trace |
| Missing required field | 422 or 400 | response names the field |
Wrong type (total: "ten") |
422 | offending field identified |
Unknown enum (status: "lost") |
422 | allowed values listed or documented |
Unknown resource (GET /orders/999999) |
404 | consistent error body |
| No auth header | 401 | WWW-Authenticate per spec |
| Wrong role | 403 | no resource data leaks |
| Oversized body (e.g. 10 MB) | 413 or 429 | bounded, documented rejection |
Read the expected column twice, because the consistency of error responses matters as much as the codes. Pick one error envelope your API uses — {"error": {"code": "VALIDATION_FAILED", "message": "...", "field": "total"}} — and assert its shape on every 4xx. An error body whose message field is a raw Java exception (java.lang.NullPointerException at com.acme.OrderService...) or a SQL fragment has leaked implementation detail to attackers and given your client no field to highlight; that deserves a defect report even though the status code was right.
Two distinctions cause endless triage arguments, so pin them down with tests. 400 versus 422: if the body cannot be parsed at all, 400; if it parses but violates business or field rules, 422 (many APIs fold both into 400 — fine, as long as your suite asserts whichever the contract documents). 404 versus 422: a malformed path parameter (GET /orders/abc where the id is numeric) is usually 422 — the URL is malformed — while a well-formed id that matches nothing is 404. Assert what your API chose; ambiguity here becomes inconsistent client handling.
Then the number that must never appear from client input: 500. A 5xx in response to a bad request means the server failed to handle an input it should have rejected — null dereference, unhandled constraint violation, timeout on a missing index. The correct output of that test is not a red build and a shrug; it is a bug report with the request, the response, and a correlation id. One useful technique for CI: run the full negative matrix against staging nightly, because 500s on error paths are often deploy-timing bugs — a new code path merged this morning that nobody exercised.
Where does the server reject who you are rather than what you sent? That is the auth matrix — missing, malformed, expired, and mis-scoped tokens — and it is precise work.
Two operational details make the negative matrix survive real usage. First, capture a correlation id on every failure: most APIs return one (X-Request-Id header or traceId in the body), and your assertion script should stash it into the test report so a red run hands the developer a log pointer instead of "it 500ed." Second, decide how the matrix scales. A handful of endpoints can hard-code one test per row; beyond that, drive the fault table from a dataset — a JSON file of {payload, expectedStatus, expectedCode} rows replayed by a single request — so adding a negative case is a data edit, not a script rewrite. That is exactly the data-driven pattern the next topics formalize, and it is also how you keep the matrix from rotting the moment a second endpoint appears.
Everything so far rejected what you sent. The matrix that rejects who you sent it as — missing, malformed, expired, and mis-scoped tokens — is the next layer: Automating Postman Collections turns these hand-built checks into a suite, and the auth-flow drills follow in the same track.
Breaking Auth on Purpose: Missing, Malformed, and Expired Tokens
Authentication failures are the rare case where the test's job is not "make it work" but "make it fail four different ways, each correctly." A bearer-token API accepts one shape of credential and must reject everything else informatively. Work the failure modes in order — they escalate from absent to invalid to insufficient, and each has its own expected status and its own bug class when mishandled.
Mode 1: no credential. Send the request with no Authorization header. Expect 401 and, per RFC 9110, a WWW-Authenticate header describing the scheme (Bearer realm="api"). A 200 here is a critical security defect; a 403 is a protocol mistake that breaks client libraries which key off 401 to trigger login.
Mode 2: malformed credential. The header exists but is garbage, and the API must still answer 401 without crashing:
Authorization: <absent> → 401
Authorization: Basic dXNlcjpwYXNz → 401 (wrong scheme)
Authorization: Bearer → 401 (empty token)
Authorization: Bearer not-a-jwt → 401 (unparsable)
Authorization: Bearer <expired-jwt> → 401 (expired — assert error code)
Authorization: Bearer <jwt signed w/ old key> → 401 (bad signature)
Authorization: bearer <valid-jwt> → per contract (scheme is case-sensitive; decide)
Two rows deserve emphasis. Expired tokens must return 401 with a distinguishable error code (TOKEN_EXPIRED) so clients know to refresh rather than re-login — but they must not be distinguishable from invalid tokens in any way that leaks account existence to an unauthenticated attacker; discuss the trade-off with your team and test the side you chose. And testing expiry should never mean waiting: create the account or token with a deliberately short TTL (seconds), or use a test-only clock endpoint if the API offers one.
Mode 3: valid token, wrong scope or role. A correctly signed, unexpired token for a user who may read orders but not delete them must yield 403 on DELETE /orders/42 — and the response body must not contain the order's data. This is where auth testing and data-leak testing merge: assert both the code and the absence of protected fields in the error body.
Mode 4: revoked or rotated credentials. If your API supports revocation, log in, revoke (or change the user's password, if that revokes sessions), then reuse the old token and expect 401. Token rotation deserves a test too: when a refresh flow issues a new access token, does the old one still validate for its full lifetime? Both answers are defensible; undocumented behavior here causes production incidents during key rotations.
In Postman, wire this cleanly: collection variable {{token}} set by a login request's test script, Authorization tab → Bearer on each protected request, and a Negative Auth/ folder holding the matrix above. Never hard-code a token into a collection — they expire, they leak into exports, and the exported file ends up in git.
Two sweeps close the loop. The first is authz coverage: enumerate every protected route and assert that each returns 401 when called anonymously — an endpoint that quietly dropped its auth middleware answers 200 to the world, and only a sweep across the full route table catches it (spot-checking three endpoints is how the unauthenticated admin route ships). The second is login abuse behavior: repeated failed logins must rate-limit (429) or add progressive delay, and account-lockout responses must not reveal whether the username exists — same response time class and body for unknown users and wrong passwords. Both sweeps are cheap in a collection: a folder of routes fed from a data file, and three login attempts with different failure profiles. When the auth matrix runs green, you have an API that fails safely; the next step is turning these scripts into a suite that runs itself: Automating Postman Collections.
Quiz
1. A `POST /orders` test asserts only `status == 201` and passes. The mobile client then crashes because `customer` came back null. What was missing from the test?
2. Your test sends `GET /orders/42` without an Authorization header and the API answers 403. Why is this likely a protocol bug worth reporting?
3. A server returns `500 Internal Server Error` when you submit `{"total": "ten"}` on POST /orders. How should you classify it?
4. How should an expired bearer token be tested without slowing the suite?
Flashcards
Question
What does a correct 201 Created response contain, and what breaks if a test ignores it?
Click to reveal answer
Answer
A body with the created resource plus a Location header pointing at it (e.g. /orders/42). A test asserting only the 201 misses missing/incorrect Location (breaking clients that navigate to it) and a body whose fields no longer match the representation contract.
Question
Why must a test treat 204 No Content differently from 200?
Click to reveal answer
Answer
204 is defined to have no body: pm.response.json() throws and any body assertion is a test bug. Assert emptiness instead. Conversely, a 200 with an unexpected empty body is an API defect.
Question
The negative-path matrix for an endpoint: what does it contain?
Click to reveal answer
Answer
One row per input-fault class (unparsable body, missing field, wrong type, unknown enum, nonexistent id, no auth, wrong role, oversized body) with the expected status and the specific assertion for each — including that error bodies use one consistent envelope and never leak stack traces or SQL.
Question
The four bearer-token failure modes and their expected outcomes.
Click to reveal answer
Answer
No header → 401 + WWW-Authenticate; malformed (wrong scheme, empty, unparsable, bad signature) → 401; expired → 401 with a distinguishable TOKEN_EXPIRED code without leaking account existence; valid token but wrong role/scope → 403 with no protected data in the body.
Question
Which response headers are worth asserting, and when?
Click to reveal answer
Answer
content-type (matches the documented body format), Location on 201, cache-control on cacheable GETs, Retry-After on 429, and rate-limit headers where documented. Assert only headers the contract promises — asserting incidental headers (Date, X-Powered-By) creates noise and blocks harmless infra changes.
Revision Notes
Key Takeaways
- 1. Status code and payload shape are independent contract axes — assert both, or a field silently turning null ships to production under a green 201
- 2. 201 must carry Location (follow it in a test), 204 must carry no body (parse it and the test itself is the bug)
- 3. Every 4xx should return one consistent error envelope; error messages that leak stack traces or SQL are reportable defects
- 4. 500 in response to client input means validation ran too late — the 500 is the bug, the bad request is only the trigger
- 5. Auth testing is a ladder: 401 for absent/malformed/expired identity, 403 for known-but-denied — test each rung, including revoked and rotated tokens
Interview Tips
- • "How do you test an API thoroughly?" — answer with the matrix: per-endpoint success contract (code + shape + headers) and a negative matrix of input faults with expected statuses; that structure signals systematic thinking
- • "401 vs 403 vs 404 — when do you see each?" — 401 no identity, 403 identity denied, 404 absent resource (or hidden by policy — mention you test which)
- • "How would you test token expiry?" — short-TTL tokens or a test clock, assert 401 with a distinct TOKEN_EXPIRED code so clients can refresh; explicitly say you avoid sleeps
- • "What makes an error response 'good'?" — stable envelope with a machine-readable code, human message, and offending field; no stack traces; consistent across endpoints; your suite asserts the envelope everywhere
Cheat Sheet
Status Codes and Auth Testing
Success contract = code + shape:
| Code | Meaning | Must assert |
|---|---|---|
| 200 | representation returned | required fields, types, enums |
| 201 | created | Location header + body id; follow Location → 200 |
| 204 | no content | empty body (never json() it) |
Shape assertions: presence + type for server fields; exhaustive oneOf for enums; envelope for lists (items array, pagination echoing the request).
Negative matrix rows: unparsable body → 400 · missing/wrong-type/unknown-enum → 422 (or documented 400) · nonexistent id → 404 · no auth → 401 (+ WWW-Authenticate) · wrong role → 403 · bad input → never 500.
Auth failure ladder: absent → 401 challenge · malformed (wrong scheme, empty, garbage, bad signature) → 401 · expired → 401 TOKEN_EXPIRED (short-TTL test tokens, no sleeps) · valid but under-scoped → 403 without data leakage · revoked → 401.
Error envelope: assert {error: {code, message, field}} consistently; a stack trace or SQL fragment in the message is a defect even when the status is right.