Skip to content
intermediate Phase 9 · API Testing

Automating Postman Collections

Write pm.expect assertions, chain requests with variables, and run via Newman in CI.

1h 5m
0 problems
Topic Progress 0%

From Visual Checks to Executable Assertions: the pm.test Sandbox

A collection of requests with no test scripts is a manual checklist: it runs only when someone clicks, and it verifies nothing when they do. Postman's scripting layer changes that. Each request carries two script slots — a Pre-request Script that runs before the send and a Tests script that runs after the response — executed in a JavaScript sandbox (a stripped Node-like environment) with one global object, pm, as your entire API.

Every assertion follows the same shape: wrap named checks in pm.test so the runner records pass/fail per check, and express the check itself with pm.expect, which is Chai's expect bound to the sandbox:

pm.test("order created and matches request", function () {
  pm.response.to.have.status(201);
  const order = pm.response.json();
  pm.expect(order.id, "server assigned an id").to.be.a("number").above(0);
  pm.expect(order.status, "starts as open").to.equal("open");
  pm.expect(order.items, "carries the requested line items").to.have.lengthOf(2);
});

pm.test("Location header points at the created order", function () {
  const loc = pm.response.headers.get("Location");
  pm.expect(loc, "Location header present").to.match(/^\/orders\/\d+$/);
});

Four mechanics decide whether your scripts stay maintainable. The optional message argument: pm.expect(value, "server assigned an id") — when this fails in CI at 2 a.m., the message is the difference between a five-minute fix and an hour of reproduction. The Chai vocabulary you will actually use: .to.equal for primitives, .to.eql for deep object equality, .to.include for substring/array membership, .to.have.property("id"), .to.be.oneOf([...]) for enums, .to.match(/regex/) for formats — and deliberately not .deep.equal on full response bodies, which breaks on every server-added field and turns your suite into a field whitelist no one dares update. Assertion layers: status, then shape, then cross-field invariants (shippedAt >= createdAt); each pm.test block should express one layer so a failure names the broken promise. Error handling: an uncaught exception in a test script marks the test errored, not just failed — wrap JSON parsing so a 500 with an HTML body produces "response was not JSON" instead of a stack trace.

The sandbox exposes more than pm.response. pm.request gives the outgoing request (headers, URL) and pm.response the incoming one; pm.cookies, pm.testRunner, and the timing helpers round it out. Pre-request scripts use the same vocabulary but read from the request side — the classic use is adding a timestamp or nonce header right before sending:

pm.variables.set("nonce", pm.variables.replaceIn("{{$guid}}"));
pm.request.headers.add({ key: "X-Nonce", value: pm.variables.get("nonce") });

One warning that saves entire afternoons: scripts run in a sandbox with no filesystem, no network of their own (pm.sendRequest excepted), and console.log output appears in the Postman Console — nowhere else. If a script silently does nothing, check that the request was actually saved with the script attached, because unsaved edits look identical in the editor and vanish on send.

Two sandbox behaviors regularly surprise people writing their first suite. First, tests run regardless of the response status — a 500 still executes your script, so an unguarded pm.response.json() throws on an HTML error page; put the status assertion at the top of each test block so the failure message says "expected 201, got 500" instead of a JSON parse exception. Second, variable writes live only in the running sandbox: an unsaved script's pm.environment.set disappears with the editor tab, which is why a half-edited suite behaves differently in the runner. For selective debugging, Postman offers pm.test.skip and the runner's per-request checkboxes — use them while iterating on one failing check, but never commit a suite with skips sprinkled through it; a skipped test is a promise nobody is keeping.

Assertions that only read the current response still need data from earlier ones — the created id, the login token. Passing values between requests is the chaining discipline: Chaining Requests covers it next.

Chaining Requests: Passing Tokens and Ids Without Hard-Coding

A regression suite against a live API is a story: log in, create an order, read it back, update it, delete it, verify the delete. Each sentence needs a fact produced by the previous one — the access token, the order id, the etag. The engineering question is where those facts live. Hard-coding them is the tempting answer and the wrong one: a token expires, an id from yesterday's run belongs to an order someone deleted, and the suite breaks in ways that say nothing about the product.

Postman's answer is variable capture. In the login request's test script, extract the value and store it:

pm.test("login issued a token", () => {
  pm.expect(pm.response.code).to.equal(200);
  const body = pm.response.json();
  pm.expect(body, "token present").to.have.property("token");
  pm.environment.set("token", body.token);
});

Every downstream request then reads {{token}} from its Authorization tab. The create-order request captures pm.environment.set("orderId", body.id) the same way, and GET /orders/{{orderId}} needs no knowledge of any specific order. The pattern generalizes to everything a run produces: an If-Match etag for optimistic-concurrency updates, a nextCursor for pagination walks, a cleanup id the final DELETE consumes.

Which scope you write to is a design decision, not a detail. pm.environment.set targets the active environment and — critically — those values persist in the environment after the run unless something resets them, which is how a suite starts passing on the second run and failing on a clean machine: the first run left orderId behind. pm.collectionVariables.set scopes the value to the collection, which is the right home for run-scoped state, and pm.variables.set is request-local scratch. The portable discipline: write run state to collection variables, keep environments for deployment config only, and make the suite self-sufficient — every request must derive what it needs from earlier requests in the same run, never from leftovers.

Two structural habits keep chains honest. First, teardown as a first-class request: the last request in the folder is DELETE /orders/{{orderId}} with its own assertion, so a green run leaves no debris and a red run halfway through leaves a breadcrumb you can find. When Newman runs with --bail (stop at first failure) teardown may be skipped — that is why cleanup also belongs in CI teardown jobs for anything billing-adjacent. Second, guard the chain explicitly: if the login failed, every subsequent request fails with 401 and the report shows five red requests instead of one. A one-line chain guard in the pre-request script of the first dependent request — pm.expect(pm.environment.get("token"), "login must have succeeded").to.be.ok — collapses the noise to the true root cause.

Capturing is not limited to the JSON body. Headers carry chained state too: pm.response.headers.get("ETag") into an If-Match header tests the optimistic-concurrency contract, and a Location header is already a complete URL you can follow without rebuilding it. The failure mode to design against is hidden coupling — request 7 silently depends on a variable set by request 3, and running the folder in isolation produces nonsense. Name the dependency in the request description and keep each folder self-contained: log in, create, use, delete, all within one folder, so a reviewer can run any folder alone and get a truthful result.

For the rare case where you must drive several requests inside one script — polling a webhook until it fires, or fetching two ids to compare — pm.sendRequest performs HTTP from the sandbox without a visible request:

pm.sendRequest(`${pm.variables.get("baseUrl")}/orders/${orderId}`, (err, res) => {
  pm.expect(err).to.be.null;
  pm.expect(res.code()).to.equal(200);
});

Chained requests are still a studio craft — they execute when someone presses Run. The point of writing them at all is that the same collection can run unattended, thousands of times a day, inside the pipeline: Newman in CI is where this becomes infrastructure.

Newman in CI: Turning a Collection into a Pipeline Gate

Newman is Postman's command-line runner: it executes an exported collection with every script, variable, and assertion the GUI honors, and — the property that makes it infrastructure — it exits non-zero on failure. That exit code is the entire bridge between "someone's Postman work" and "a merge gate": CI systems branch on nothing else.

The minimal invocation, run from the directory holding your exported files:

newman run orders.postman_collection.json \
  -e staging.postman_environment.json \
  --env-var "token=$STAGING_TOKEN" \
  --reporters cli,junit \
  --reporter-junit-export reports/orders-junit.xml

Each flag earns its place. -e selects the environment file — commit the non-secret one (hosts, flags) next to the collection so reviewers see stage config in the same diff. --env-var injects secrets from the CI secret store at run time; a token that exists only in a masked CI variable cannot leak into git. --reporters cli,junit keeps console output for humans while emitting a JUnit XML file the CI understands natively, so test results appear in the standard panels every other job uses. Add --bail only for smoke suites — in a full regression run you want every failure, not just the first.

A GitHub Actions job that turns that command into a pull-request gate:

api-tests:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: npm
    - run: npm ci
    - run: npx newman run tests/api/orders.postman_collection.json -e tests/api/staging.postman_environment.json --env-var "token=${{ secrets.STAGING_TOKEN }}" --reporters cli,junit --reporter-junit-export reports/junit.xml
    - uses: actions/upload-artifact@v4
      if: always()
      with:
        name: newman-reports
        path: reports/

The details that separate a pipeline people trust from one they re-run: cache the npm step (cache: npm above — Newman installs in seconds instead of a minute per run); upload reports with if: always() so a failed run still leaves its JUnit file and any console logs attached to the job; and never mark the job continue-on-error — a gate that cannot go red gates nothing, and the team learns to ignore it within a week.

Parameterization across stages falls out of the same design. The collection references {{baseUrl}}; local, staging, and prod environments supply it; the pipeline chooses with -e and, where the same environment is reused per branch, with --env-var baseUrl=$DEPLOY_URL. Keep two categories of run separate on purpose: a PR smoke suite (--folder "Create" --folder "Read" or a dedicated smoke collection) that must finish in a couple of minutes against the ephemeral preview deployment, and a nightly full regression against long-lived staging where rate limits are configured for machine traffic — a suite that hammers a shared staging environment at 10x human load earns flaky 429s and distrust.

Newman also gives CI-friendly output knobs worth knowing before your first incident: --timeout-request 10000 fails a hung request instead of freezing the job until the runner's global timeout, and --verbose surfaces DNS and TLS failures that a bare "connection error" hides.

Two last operational choices, both about reproducibility. Pin Newman's version (newman@6 in a dev dependency or a pinned Docker image postman/newman:6) rather than whatever npx resolves that morning — runner upgrades change report formats and occasionally flag syntax the GUI tolerated, and you want that discovered in a controlled bump, not in a release-day pipeline. And treat the JUnit file as a first-class artifact: publish it to the CI test panel so pass/fail history per request is charted over time, and diff its failure names across runs — three consecutive runs failing the same single test is a product bug wearing a flaky-suite costume. When your collection is gating merges, its reliability becomes your responsibility — and one collection can only cover so much input variety before the permutations demand a dataset: Data-Driven API Testing is where that pressure resolves.

Quiz

1. A Newman run passes on a developer's machine but fails on a clean CI clone with 401s on every request after login. What is the most likely cause?

Question 1 options

2. Which scripting practice produces the most useful failure message when a 201 response lacks the `id` field?

Question 2 options

3. Where should a login token obtained during a Newman run be stored so the suite is deterministic across machines?

Question 3 options

4. A Newman step exits 1 on a pull request. Which configuration error would make the failure invisible to the team?

Question 4 options

Flashcards

Question

What runs in a Postman request's Pre-request Script versus its Tests script?

Answer

Pre-request Script runs before the send (add nonce/timestamp headers, compute values, set variables); Tests runs after the response (assertions via pm.test/pm.expect, capture response values into variables). Both execute in the same pm sandbox with no filesystem and no ambient network.

Question

pm.environment.set vs pm.collectionVariables.set vs pm.variables.set — which for run state?

Answer

pm.variables.set is request-local scratch; pm.collectionVariables.set is the right home for run-scoped shared state (captured ids, tokens) because it dies with the run's scope; pm.environment.set writes to the environment, whose values persist across runs and leak state between executions — reserve environments for deployment config, not run state.

Question

What does Newman's exit code do for CI, and which flags matter most?

Answer

Non-zero on any failed test — that is the gate. Key flags: -e for the environment file, --env-var for CI-injected secrets, --reporters cli,junit (+ --reporter-junit-export) for machine-readable results, --timeout-request to fail hung requests, --bail only for smoke runs, --iteration-data for datasets.

Question

How do you chain a created resource's id into the next request?

Answer

In the creating request's test script: pm.environment/collectionVariables.set('orderId', pm.response.json().id); downstream requests reference {{orderId}} in the URL. Add a chain guard (assert the variable is set) in the first dependent request so a failed create yields one clear failure, not five 401/404s.

Question

Why assert response shape instead of deep-comparing the whole body?

Answer

deep.equal makes every server-added field a test failure — a whitelist disguised as a golden file that no one dares update. Layered assertions (status, presence/type of documented fields, cross-field invariants) pin the contract while tolerating additive change, which is exactly what backward compatibility means.

Revision Notes

Key Takeaways

  • 1. A request without a test script verifies nothing — pm.test blocks turn a collection into an executable specification, and the optional message argument is what makes 2 a.m. CI failures debuggable
  • 2. Chain by capturing in the producing request's script and referencing {{variable}} downstream; run state belongs in collection variables, not environments, or clean machines fail
  • 3. Newman's non-zero exit code is the whole CI integration — reporters, timeouts, and artifact uploads are what make the failure actionable
  • 4. A gate marked continue-on-error stops gating; teams learn to ignore it within a week
  • 5. Separate PR smoke suites from nightly full regressions so feedback stays fast without starving coverage

Interview Tips

  • • "How do you automate API tests?" — walk the loop: collection with per-request assertions, values captured into variables for chaining, Newman in CI with JUnit output gating the PR; mention environment/secret separation
  • • "How do you pass data between API tests?" — capture in the test script of the producer, reference via variables, guard against missing state; contrast with hard-coded ids and explain the clean-machine failure mode
  • • "What makes a CI test gate trustworthy?" — deterministic (no persisted state), fast (smoke on PR, full nightly), actionable (JUnit + artifacts on failure), and never continue-on-error
  • • "How do you avoid brittle response assertions?" — layered checks (status, documented fields' presence/type, invariants) instead of whole-body golden comparisons, plus enum oneOf assertions to catch additive changes that break clients

Cheat Sheet

Automating Postman Collections

Script slots: Pre-request (before send) · Tests (after response) — both in the pm sandbox.

Assertion shape:

pm.test("name shown in report", function () {
  pm.response.to.have.status(201);
  const body = pm.response.json();
  pm.expect(body.id, "server-assigned id").to.be.a("number").above(0);
  pm.expect(body.status).to.be.oneOf(["open", "shipped"]);
});

Chai tools: equal / eql / include / have.property / oneOf / match. Always pass a message. Never deep-compare whole bodies.

Run state: capture with ...Variables.set(key, value) in the producer's test script; consume with {{key}}. Collection variables for run state, environments for deployment config only — pm.environment.set persists across runs and is the classic clean-machine failure.

Newman gate:

newman run collection.json -e staging.json --env-var "token=$TOKEN" \
  --reporters cli,junit --reporter-junit-export reports/junit.xml --timeout-request 10000

CI: npm ci with cache → newman → upload artifacts if: always(). Never continue-on-error on a gate.

Suite shape: PR smoke (folders/subset, minutes) · nightly full regression (all folders + negatives) with rate limits configured.

Continue Learning

Related Reading