Real responses aren't flat. They're nested objects with arrays of objects with more nested objects. Asserting on them — and pulling values out for use in the next request — is where Postman tests start to feel like real code. This lesson is about navigating JSON inside the Tests tab, validating shapes deeply, storing extracted values for chained requests, and running full-blown JSON Schema validation. The masterclass JSON Schema Validation lesson covered the why; this is the Postman-flavoured how.
The data extraction loop
Most body tests follow the same four-step rhythm: parse the JSON, navigate to the value, validate it, and (if you'll need it later) store it as a variable.
Parseconst body = pm.response.json(). Postman…
NavigateWalk to the value: body.data.user.orders…
ValidateAssert on type, shape, value: pm.expect(…
StoreSave for the next request: pm.collection…
Parse, navigate, validate, store. The same shape covers everything from a one-line id extraction to a 30-line schema check.
Parse and navigate
Start every body test by parsing once and navigating with regular JavaScript:
The ?. returns undefined instead of throwing if any step in the chain is missing. The ?? 0 provides a default. Without those, a missing field somewhere mid-path would crash the entire test script with a TypeError.
Storing extracted values
The point of extraction often isn't just to assert on the value — it's to use it in a later request. Postman makes this trivial with the variable scopes you learned in Chapter 2:
The next request can now reference {{authToken}}, {{userId}}, and {{lastOrderId}} anywhere — URL, headers, body, params. We'll wire this into a full chain in Chapter 4 Lesson 1.
The choice of scope mirrors the variable rules:
Environment — scope-tied (a token tied to staging).
Collection — scope-free (an id you keep using regardless of environment).
Globals — almost never. Globals leak everywhere.
Validating nested structures
A full structural assertion validates types, required keys, and value constraints in one block:
pm.test("User has the correct structure", () => { const user = pm.response.json().data; pm.expect(user).to.have.all.keys("id", "name", "email", "role", "createdAt"); pm.expect(user.id).to.be.a("number"); pm.expect(user.name).to.be.a("string"); pm.expect(user.email).to.match(/.+@.+\..+/); pm.expect(user.role).to.be.oneOf(["admin", "user", "viewer"]); pm.expect(user.createdAt).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO date prefix});
That's a "shape lock" — when the backend introduces a new field, to.have.all.keys fails and the test points you at the change. Some teams prefer to.include.all.keys(...) instead, which allows extra fields but still requires the listed ones. Pick whichever matches your culture: strict locks catch silent additions; loose locks reduce churn.
Validating arrays of objects
The pattern: assert the array has the expected shape, then loop and assert each element:
pm.test("Products list is valid", () => { const products = pm.response.json().products; pm.expect(products).to.be.an("array").with.lengthOf.above(0); products.forEach((product, index) => { pm.expect(product, `product at index ${index}`).to.have.property("id"); pm.expect(product.price).to.be.a("number").and.to.be.above(0); pm.expect(product.name).to.be.a("string").and.to.have.lengthOf.above(0); pm.expect(product.inStock).to.be.a("boolean"); });});
Two things worth noticing:
The second arg to pm.expect(value, "message") is a label — when an assertion fails, the error includes the label. Without it, "expected null to be a number" doesn't tell you which product was broken.
Looping with forEach means a single broken product fails the whole test. That's usually what you want — you'd rather know "products[7] is missing a price" than have all 50 tests pass except one.
JSON Schema validation in Postman
For complex responses, hand-rolling assertions gets repetitive. JSON Schema is a standardised vocabulary for describing JSON shapes — type, required fields, formats, ranges, the lot. Postman ships the tv4 validator out of the box.
additionalProperties: false means exactly these fields. Drop it if extras are allowed.
The second argument to pm.expect (tv4.error) is what gets shown when the schema fails — without it, you'd just see "expected false to be true" and no idea what mismatched. Always pass the error in.
When does schema validation pay for itself?
The response has more than ~5 fields.
The same shape is returned by multiple endpoints (define the schema once, reuse it).
The team owns an OpenAPI spec — the schemas come for free from the spec's components.schemas.
For tiny responses, hand-written pm.expect is faster to read. Both approaches coexist happily in the same collection.
Reusing schemas across requests
If you're using the same schema in multiple requests, store it as a collection variable (yes, you can store objects — Postman serialises them):
// Pre-request script (or once in a setup test)pm.collectionVariables.set("userSchema", JSON.stringify({ type: "object", required: ["id", "name", "email"], properties: { /* ... */ }}));
Schemas tend to grow; keeping the canonical version in one place is worth the indirection.
A full extract-and-validate example
Putting it all together — a test on POST Create User that validates the response, locks the shape, and stores the new id for chaining:
const response = pm.response.json();pm.test("Status is 201 Created", () => { pm.response.to.have.status(201);});pm.test("Response has the user shape", () => { pm.expect(response).to.have.all.keys("id", "name", "email", "createdAt"); pm.expect(response.id).to.be.a("number"); pm.expect(response.email).to.match(/.+@.+/);});pm.test("Created user matches what we sent", () => { const sent = JSON.parse(pm.request.body.raw); pm.expect(response.name).to.equal(sent.name); pm.expect(response.email).to.equal(sent.email);});// Store for the next request in the chain.pm.collectionVariables.set("createdUserId", response.id);console.log("Stored createdUserId =", response.id);
The console.log shows up in the Postman Console (Cmd/Ctrl+Alt+C) — leave one in any extraction script while you're learning so you can verify the value was captured.
⚠️ Common mistakes
No null-guarding nested paths.body.data.user.id crashes the whole script if body.data is null. Use body?.data?.user?.id or guard with an if.
Forgetting to JSON.parse the request body.pm.request.body.raw is a string. Compare it to the response with JSON.parse(pm.request.body.raw), not against the string directly.
Schema with no error message. A passing schema test is silent; a failing one shows "expected false to be true" without tv4.error as the second arg. Always pass tv4.error (or an equivalent) so failures are debuggable.
🎯 Practice task
Extract, validate, and store. 30 minutes.
Open GET User by ID (/users/1). In the Tests tab, parse the body and write three assertions: id is a number, email matches /@/, address.city is a non-empty string.
Add a "shape lock" test using pm.expect(body).to.have.all.keys(...) listing every field the JSONPlaceholder /users/1 response returns. Send. Adjust the list until the test passes. Now your test will fail loudly if the API ever changes the shape.
Open POST Create Post. After the existing assertions, add: pm.collectionVariables.set("createdPostId", pm.response.json().id); Send. Open the eye-icon variable view — confirm createdPostId has a value.
Now open GET User by ID again. Change the URL to {{baseUrl}}/posts/{{createdPostId}}. Send. The variable from step 3 should resolve and you should get the post you "created". (JSONPlaceholder is faking it; the principle is what matters.)
Schema validation. Open GET User by ID. Paste this schema-validation block:
Send. Test should pass. Now break the schema (e.g. set id: { type: "string" }) and re-send — the failure message should show the exact field that mismatched.
Stretch: open GET All Users and write a forEach loop that schema-validates every user in the array against the same schema. (Hint: loop, validate, fail the test if any single one is invalid.)
You can now navigate, validate, and shuttle nested JSON around inside Postman. The next lesson covers the other JavaScript hook — pre-request scripts — for setting up dynamic data before the request goes out.
// tip to track lessons you complete and pick up where you left off across devices.