Almost every API test you'll ever write asserts on one of three things: the status code, something inside the body, or a response header. The masterclass Status Code Validation lesson covered the why; this lesson covers the how in Postman, including the assertion syntax for each family and the negative assertions that stop bugs from sneaking through. By the end you'll have a small but battle-tested vocabulary that handles 80% of real test cases.
The three assertion families
The assertion vocabulary you'll use 80% of the time
| Most common | Range / shape | Negative | |
|---|---|---|---|
| Status code | pm.response.to.have.status(200) | pm.expect(pm.response.code).to.be.within(200, 299) | pm.expect(pm.response.code).to.not.equal(500) |
| Response body | pm.expect(body).to.have.property('id') | pm.expect(body).to.be.an('array').with.lengthOf.above(0) | pm.expect(body).to.not.have.property('password') |
| Headers | pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json') | pm.response.to.have.header('X-Request-Id') | pm.expect(pm.response.headers.get('X-Debug')).to.be.null |
Three families × three patterns = nine moves that cover most working tests. Let's walk through each.
Status code assertions
The simplest case — assert an exact code:
pm.test("Status is 200 OK", () => {
pm.response.to.have.status(200);
});When you don't care exactly which 2xx code (some endpoints return 200 sometimes and 201 other times), assert a range:
pm.test("Status is 2xx success", () => {
pm.expect(pm.response.code).to.be.within(200, 299);
});The reason phrase variant is cosmetic — pm.response.to.have.status("OK") works but is less reliable across servers since reason phrases vary.
For permission tests (covered in the masterclass), use specific codes — 401 vs 403 mean different things:
pm.test("Unauthenticated request returns 401", () => {
pm.response.to.have.status(401);
});
pm.test("Forbidden action returns 403", () => {
pm.response.to.have.status(403);
});Response body assertions
The body is where most of your tests will live. Get it as a parsed object first:
const body = pm.response.json();Then assert properties exist, types are right, and values match:
const body = pm.response.json();
pm.test("Response has users array", () => {
pm.expect(body).to.have.property("users");
pm.expect(body.users).to.be.an("array");
});
pm.test("First user has correct name", () => {
pm.expect(body.users[0].name).to.equal("Alice");
});
pm.test("All users have an email field", () => {
body.users.forEach(user => {
pm.expect(user).to.have.property("email");
pm.expect(user.email).to.include("@");
});
});The forEach pattern is powerful: it runs the same set of expectations against every item in an array. Any failure inside the loop fails the whole test, with the assertion message pointing at the broken field.
For arrays, the most useful matchers:
pm.expect(body).to.be.an("array");
pm.expect(body).to.have.length(10);
pm.expect(body).to.have.lengthOf.above(0);
pm.expect(body).to.have.lengthOf.at.most(50);
pm.expect(body.map(u => u.id)).to.include(1);For string body (e.g. plain-text health checks), use pm.response.text() instead of .json():
pm.test("Body contains success message", () => {
pm.expect(pm.response.text()).to.include("ok");
});Header assertions
Two equivalent ways to assert a header exists:
pm.test("Has X-Request-Id header", () => {
pm.response.to.have.header("X-Request-Id");
});
pm.test("Has X-Request-Id header (alt)", () => {
pm.expect(pm.response.headers.get("X-Request-Id")).to.exist;
});For Content-Type, asserting exact equality is fragile because servers append ; charset=utf-8:
// Brittle — fails if charset is appended:
pm.test("Content-Type is application/json", () => {
pm.response.to.have.header("Content-Type", "application/json");
});
// Robust — matches with or without charset:
pm.test("Content-Type is JSON", () => {
pm.expect(pm.response.headers.get("Content-Type")).to.include("application/json");
});The robust form is what you should default to. The exact-match form has its place when you genuinely need to confirm "and only that" — but for most tests, the include check is what you want.
Other headers worth asserting on:
pm.test("Cache-Control disables caching for sensitive endpoints", () => {
pm.expect(pm.response.headers.get("Cache-Control")).to.include("no-store");
});
pm.test("Rate-limit headers are present", () => {
pm.response.to.have.header("X-RateLimit-Remaining");
pm.response.to.have.header("X-RateLimit-Limit");
});
pm.test("Security headers are set", () => {
pm.response.to.have.header("Strict-Transport-Security");
pm.response.to.have.header("X-Content-Type-Options");
});Negative assertions — what shouldn't be there
A passing test that only asserts what should be present misses a class of bug: things that shouldn't be present but are. Login responses leaking password hashes, internal debug headers exposed in production, deprecated fields still in the wire format. Catch these with negative assertions.
pm.test("Response does not leak password", () => {
const body = pm.response.json();
pm.expect(body).to.not.have.property("password");
pm.expect(body).to.not.have.property("passwordHash");
});
pm.test("Internal debug headers are stripped", () => {
pm.expect(pm.response.headers.get("X-Debug-Trace")).to.be.null;
pm.expect(pm.response.headers.get("X-Internal-Cache-Hit")).to.be.null;
});
pm.test("Status is not 500", () => {
pm.expect(pm.response.code).to.not.equal(500);
});For login endpoints especially, a to.not.have.property("password") test has caught real production bugs. Always include at least one negative assertion in any auth-related test.
A complete test set for POST /login
Putting all three families together, here's the kind of comprehensive test set you'd attach to a real login endpoint:
const body = pm.response.json();
pm.test("Status is 200 OK", () => {
pm.response.to.have.status(200);
});
pm.test("Response time is acceptable", () => {
pm.expect(pm.response.responseTime).to.be.below(800);
});
pm.test("Content-Type is JSON", () => {
pm.expect(pm.response.headers.get("Content-Type")).to.include("application/json");
});
pm.test("Response contains a token", () => {
pm.expect(body).to.have.property("token");
pm.expect(body.token).to.be.a("string").and.to.have.lengthOf.above(20);
});
pm.test("Response contains a user object with safe fields only", () => {
pm.expect(body).to.have.property("user");
pm.expect(body.user).to.have.all.keys("id", "email", "name", "role");
});
pm.test("Response does not leak password fields", () => {
pm.expect(body.user).to.not.have.property("password");
pm.expect(body.user).to.not.have.property("passwordHash");
pm.expect(body.user).to.not.have.property("salt");
});
pm.test("Token is a valid JWT shape", () => {
pm.expect(body.token).to.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/);
});Seven tests, each with a single concern. One of them (the negative not.have.property("password") checks) might never fail — until the day a developer accidentally adds it back during a refactor and your test catches it before production.
Chai matchers worth knowing
Postman ships Chai BDD. The matchers you'll reach for most:
to.equal(x)— strict equality (===).to.eql(x)— deep equality (use for objects/arrays).to.be.a(type)— type check:"string","number","array","object".to.be.an("array")—aandanare aliases.to.be.true/to.be.false/to.be.null/to.be.undefined/to.exist.to.include(x)— substring or array-contains.to.have.property("name")— object has the key.to.have.all.keys("a", "b", "c")— object has exactly these keys.to.have.lengthOf.above(0)/at.most(50)— length checks.to.match(/regex/)— regex.to.be.oneOf([...])— value in a set.to.be.within(low, high)— inclusive numeric range.to.not.X— negate any of the above.
Chain with .and.: pm.expect(value).to.be.a("string").and.to.have.lengthOf.above(0).
⚠️ Common mistakes
- Asserting only on status codes. A 200 response with a malformed body is still a bug. Always assert on at least one body field alongside the status — at minimum, that the body has the expected top-level shape.
- Exact-matching
Content-Type. Servers append; charset=utf-8and your test breaks. Default to.include("application/json")over.have.header("Content-Type", "application/json"). - No negative assertions. Tests that only check what should be there miss a class of bug. Add at least one
to.not.have.property(...)to every auth-related response and to every endpoint that returns user data.
🎯 Practice task
Build a comprehensive test set. 25-30 minutes.
- In your
JSONPlaceholder API Testscollection, openGET User by ID(/users/1). Paste a test set with at least one assertion from each family:- Status code:
200. - Body: response is an object with
id,name,email, andaddressproperties;emailincludes an@. - Header:
Content-Typeincludesapplication/json.
- Status code:
- Send. All tests should pass. Open the Test Results tab and confirm the count badge.
- Add a negative assertion:
pm.expect(body).to.not.have.property("password"). JSONPlaceholder doesn't return one, so this passes — but the principle is what matters. - Open
POST Create Post. Write tests covering: status is201, body hasid,idis a number, body'stitlematches what you sent, andContent-Typeis JSON. - Open
DELETE Post. Add a test: status is200, body is{}(an empty object). Hint:pm.expect(Object.keys(body)).to.have.lengthOf(0). - Force a failure. Change one of the status assertions to expect
999. Send. Read the failure message in Test Results — it shows exactly which assertion broke and what the actual value was. Revert. - Stretch: add a "shape lock" test on
GET All Usersthat usesto.have.all.keys(...)to assert that user objects have exactly the expected fields and no extras. New fields the backend adds will fail this test — caught early.
Status, body, headers — three families, one consistent grammar. The next lesson zooms into the body specifically: extracting nested values, validating arrays of objects, and using JSON Schema for full-shape assertions.