Postman in Depth: Collections, Environments, Variable Scopes, Scripts, Tests, Mock Servers and the CLI
Keeping the Postman collection in the repo and running it in CI with Newman or the Postman CLI is the most robust answer to the single-user free plan since March 2026. A hand-written v2.1 collection for Bookstore: OpenAPI import, variable scope precedence, shared versus local values, a pre-request script that gets the token once, pm.test, an iteration failing on a data file, a JUnit report, and why the Postman CLI's local mock returns useless data.
Version note: The code in this post was built and run with .NET 10; the collection was run with Newman 6.2 and Postman CLI 1.90, and the OpenAPI conversion with openapi-to-postmanv2 6.4. The Postman desktop app's screens, Native Git and the cloud features (cloud mock servers, Vault, plan limits) are taken from Postman's official documentation; I did not create a Postman account or sign in to the app for this post. Plan details are as of 14 November 2026.
Guess First
A Postman collection has a collection variable named isbn whose value is a book's ISBN: 9780261102217, The Hobbit. You run the same collection with this CSV file so that only the "Book by ISBN" request runs four times:
isbn,expectedTitle
9780441172719,Dune
9780141439587,Emma
9780261102217,The Hobbit
9780451524935,Animal Farm
Which books are requested in the four iterations: The Hobbit four times, or the four books in the CSV? Is the login repeated in every iteration? And look at the last line: is 9780451524935 really Animal Farm? What is the run's exit code? The answers are in the "Variable Scopes" and "Collection Runner" sections, with Newman's real output.
What I Could Run in Postman, and What I Describe from the Docs
Postman is the tool with the most features in the first post's matrix: collections, environments, scripts, mock servers, monitors, team workspaces. Some of these need an account and the cloud. As a rule in this series I do not create accounts, so let me draw the line up front. Everything you see as a real run (the collection itself, Newman and Postman CLI output, the OpenAPI conversion, the local mock) ran on this machine without an account. The desktop app's UI, cloud mock servers, Vault and the app side of Native Git are taken from the Postman Learning Center.
I did not build Bookstore's collection by clicking in the app; I wrote it by hand in Postman's Collection v2.1 JSON format and put it into the repo in a postman/ folder: Bookstore.postman_collection.json, dev.postman_environment.json and the data file books.csv. In the app it opens via File, Import; in CI it runs from the command line.
Importing a Collection from OpenAPI
Most teams start by importing Bookstore.Api's /openapi/v1.json into Postman. Since I could not run the import in the app, I did the same conversion with the Postman team's open source converter openapi-to-postmanv2, asking it to build folders from the OpenAPI tags:
$ curl -s localhost:5080/openapi/v1.json -o openapi.json
$ npx openapi2postmanv2 -s openapi.json -o imported.json -p -O folderStrategy=Tags
Conversion successful, collection written to file
$ jq '{auth, variable, folders: [.item[] | {name, requests: [.item[]? | .name]}]}' imported.json
{
"auth": null,
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:5080"
}
],
"folders": [
{
"name": "Identity",
"requests": [
"/api/auth/login",
"/api/auth/me"
]
},
{
"name": "Catalog",
"requests": [
"/api/books",
"/api/books/:isbn",
"/api/categories",
"/api/books/:isbn/cover",
"/api/books/:isbn/cover"
]
},
{
"name": "Ordering",
"requests": [
"/api/carts",
"Get Cart",
"/api/carts/:cartId/items/:isbn",
"/api/carts/:cartId/items/:isbn",
"/api/orders",
"Get Order"
]
}
]
}
Three things stand out. The folders come from the endpoints' WithTags("Catalog") calls. The request names are mostly paths; only the two endpoints that have WithName("GetCart") and WithName("GetOrder") got a readable name. Of the two /api/books/:isbn/cover lines one is GET and one is PUT, but you cannot tell them apart by name. Adding descriptions such as WithSummary("Upload a book cover") to the endpoints makes the collection readable too. The third is auth: the Bearer security scheme we added to the OpenAPI document in the first post paid off. The protected requests came with Bearer auth on their own:
$ jq '.item[2].item[4] | {name, auth: .request.auth, body: .request.body.raw}' imported.json
{
"name": "/api/orders",
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{bearerToken}}"
}
]
},
"body": "{\n \"cartId\": \"<uuid>\",\n \"customerName\": \"<string>\",\n \"customerEmail\": \"<string>\"\n}"
}
The import gives a good skeleton, but it is a starting point. Bodies come with <string> placeholders, there are no tests and no chaining between requests. The real problem is keeping it current: when the API changes, a re-import produces a new collection and does not carry over the scripts and tests you added by hand. There are two ways. You use the import only for the first skeleton and then manage the collection in the repo like code; that is what I did for Bookstore. Or you rely on Postman's features that link a spec to a collection; then the team has to agree on where the hand-written parts live.
Variable Scopes, Shared and Local Values
According to Postman's documentation there are five variable scopes. From broadest to narrowest: global, collection, environment, data and local. When the same name exists in more than one scope, the narrowest wins, so the order of precedence is local, data, environment, collection, global. In Bookstore's collection I assigned the roles like this:
- Environment (
dev.postman_environment.json): everything that changes per environment:baseUrl,email,password. - Collection: values carried between requests (
accessToken,cartId,orderUrl) and defaults (isbn,expectedTitle). - Data: the row from the CSV/JSON file given to the Collection Runner or the CLI.
- Local: a value a script sets only for that request (
pm.variables.set).
The environment file:
{
"id": "2d4c8a1e-7b3f-4e6a-8c2d-5f9e1a0b3c47",
"name": "Bookstore dev",
"values": [
{
"key": "baseUrl",
"value": "http://localhost:5080",
"type": "default",
"enabled": true
},
{
"key": "email",
"value": "ada@example.com",
"type": "default",
"enabled": true
},
{
"key": "password",
"value": "",
"type": "secret",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
password is empty and of type secret. The value is given to the CLI with --env-var password=..., and in the app as the person's own local value. Postman changed its terms here: instead of the "initial value" and "current value" you see in older docs, it now says "shared value" and "local value". The shared value syncs to the Postman cloud and everyone who can see the workspace sees it. The local value does not sync. The typical accident is typing a token or password into the shared value field. The value is now in the cloud, with every member of the workspace and in the file of anyone who exports the environment. The secret type masks the value in the UI but does not stop it from syncing.
For real secrets there is Postman Vault. Secrets in the Local Vault stay only in your Postman and do not go to the cloud; it is on all plans. The Shared Vault is shared with the team and is on the Solo, Team and Enterprise plans. Connecting to an external vault from the CLI and CI belongs to the Enterprise plan. That is why the simplest way in CI is to pass the value from a pipeline secret with --env-var.
Scripts: Getting the Token Once and Keeping It, and pm.test
The collection's auth is Bearer with the value {{accessToken}}; every request inherits it. The login and catalog requests are noauth. The token is fetched by the collection-level pre-request script, which runs before every request and does not log in again until the token is about to expire:
// Gets a token once and reuses it until 30 seconds before it expires.
if (pm.info.requestName === "Login") {
return;
}
const token = pm.collectionVariables.get("accessToken");
const expiresAt = Number(pm.collectionVariables.get("accessTokenExpiresAt") || 0);
if (token && Date.now() < expiresAt - 30000) {
return;
}
pm.sendRequest({
url: pm.variables.replaceIn("{{baseUrl}}/api/auth/login"),
method: "POST",
header: { "Content-Type": "application/json" },
body: {
mode: "raw",
raw: JSON.stringify({ email: pm.variables.get("email"), password: pm.variables.get("password") })
}
}, (error, response) => {
pm.test("automatic login succeeds", () => {
pm.expect(error).to.equal(null);
pm.expect(response.code).to.equal(200);
});
const body = response.json();
pm.collectionVariables.set("accessToken", body.accessToken);
pm.collectionVariables.set("accessTokenExpiresAt", Date.now() + body.expiresIn * 1000);
console.log(`new token for ${pm.variables.get("email")}, expires in ${body.expiresIn}s`);
});
Building the body with JSON.stringify is the same thing we did with jq -n --arg in the second post: the JSON does not break even if the password contains quotes. Reading expiresIn from the API's response means the script does not have to change when the token lifetime changes.
Post-response scripts (the "Tests" tab under Postman's old name) write assertions with pm.test and leave values for later requests. "Place order" and "Read order":
pm.test("status is 201 with a Location header", () => {
pm.response.to.have.status(201);
pm.response.to.have.header("Location");
});
pm.collectionVariables.set("orderUrl", pm.response.headers.get("Location"));
// The order id comes from the Location header of "Place order".
pm.variables.set("orderId", pm.collectionVariables.get("orderUrl").split("/").pop());
The second piece is the pre-request script of "Read order"; since it puts orderId into the local scope, it exists only for that request. Its URL is {{baseUrl}}/api/orders/{{orderId}}.
Collection Runner, Newman and the Postman CLI
In the app you open the Collection Runner from the collection's menu, pick the environment, and optionally give a data file and an iteration count. Since the plan change in March 2026, collection runs are unlimited on all plans. The command-line equivalent of the same run is done with two tools. Newman is the open source runner used for years. The Postman CLI is Postman's own CLI; according to the guide for migrating from Newman, Newman can only run the v2.1 JSON format, the Postman CLI also runs the v3 YAML format, and Postman recommends the CLI for new work. Both were installed into the project folder on this machine with npm install. The Postman CLI package wants to run a postinstall script after installation; the npm 11.19 I used requires approval for install scripts by default, so it did not run, and the CLI still worked. Its documentation says the Postman CLI does not require sign-in when running a local file, and only sends results to the cloud if you are signed in. I did not sign in. The whole collection with Newman:
$ npx newman run postman/Bookstore.postman_collection.json -e postman/dev.postman_environment.json \
--env-var password=Bookstore-Demo-1 --color off
newman
Bookstore API
❏ Identity
↳ Login
POST http://localhost:5080/api/auth/login [200 OK, 538B, 19ms]
✓ status is 200
✓ token type is Bearer
↳ Me
POST http://localhost:5080/api/auth/login [200 OK, 538B, 1ms]
✓ automatic login succeeds
┌
│ 'new token for ada@example.com, expires in 900s'
└
GET http://localhost:5080/api/auth/me [200 OK, 215B, 4ms]
✓ token belongs to the environment user
❏ Catalog
↳ List books
GET http://localhost:5080/api/books?page=1&pageSize=5 [200 OK, 860B, 154ms]
✓ status is 200
✓ page has at most 5 books
✓ response time is below 500 ms
...
↳ Read order
GET http://localhost:5080/api/orders/6 [200 OK, 334B, 4ms]
✓ order is Placed
┌─────────────────────────┬───────────────────┬──────────────────┐
│ │ executed │ failed │
├─────────────────────────┼───────────────────┼──────────────────┤
│ requests │ 9 │ 0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│ assertions │ 12 │ 0 │
└─────────────────────────┴───────────────────┴──────────────────┘
You can see that the pre-request script logged in only once: there was no token before the "Me" request, so it got one; the next seven requests used the same token. The explicit "Login" request does not store the token; it only tests the login endpoint itself. The run with the data file from the guessing question:
$ npx newman run postman/Bookstore.postman_collection.json -e postman/dev.postman_environment.json \
--env-var password=Bookstore-Demo-1 --folder "Book by ISBN" -d postman/books.csv --color off
Iteration 1/4
❏ Catalog
↳ Book by ISBN
POST http://localhost:5080/api/auth/login [200 OK, 538B, 27ms]
✓ automatic login succeeds
┌
│ 'new token for ada@example.com, expires in 900s'
└
GET http://localhost:5080/api/books/9780441172719 [200 OK, 389B, 18ms]
✓ 9780441172719 is Dune
Iteration 2/4
↳ Book by ISBN
GET http://localhost:5080/api/books/9780141439587 [200 OK, 382B, 32ms]
✓ 9780141439587 is Emma
Iteration 3/4
↳ Book by ISBN
GET http://localhost:5080/api/books/9780261102217 [200 OK, 389B, 8ms]
✓ 9780261102217 is The Hobbit
Iteration 4/4
↳ Book by ISBN
GET http://localhost:5080/api/books/9780451524935 [200 OK, 378B, 12ms]
1. 9780451524935 is Animal Farm
...
1. AssertionError 9780451524935 is Animal Farm
iteration: 4 expected '1984' to deeply equal 'Animal Farm'
at assertion:0 in test-script
inside "Catalog / Book by ISBN"
$ echo $?
1
The answers: the data scope is narrower than the collection scope, so in each iteration the isbn from the CSV won and four different books were requested. Login happened only in the first iteration, because collection variables are kept across iterations during a run. 9780451524935 is Orwell's 1984; the last line is wrong on purpose, the test failed and the exit code was 1. That is the signal CI needs.
The same collection with the Postman CLI and a JUnit report. Newman needs a separate reporter package for HTML reports, while the Postman CLI has it built in:
$ npx postman collection run postman/Bookstore.postman_collection.json -e postman/dev.postman_environment.json \
--env-var password=Bookstore-Demo-1 -r cli,junit --reporter-junit-export reports/postman-junit.xml --color off
Running your collection...
postman
Bookstore API
Folder Identity
Sub-folder Login
POST http://localhost:5080/api/auth/login [200 OK, 538 B, 12 ms]
Pass status is 200
Pass token type is Bearer
...
Sub-folder Read order
GET http://localhost:5080/api/orders/7 [200 OK, 334 B, 3 ms]
Pass order is Placed
Generating run report [junit]...
Saving run report [junit]...
Report created successfully! Saved to: .../reports/postman-junit.xml
...
| assertions | 12 | 0 |
The parameters are almost the same: according to the migration guide, -e, -d, -n, --env-var, --bail and -r junit are written the same in both tools, and --folder becomes -i in the Postman CLI.
Mock Server: Building Angular Against an Endpoint That Does Not Exist Yet
On the Bookstore team, Ayşe wants to build the order history page in bookstore-web; Emre's endpoint is not ready yet. Postman's cloud mock server returns the saved example responses in the collection from a URL. You choose which example comes back with headers such as x-mock-response-code. Since the cloud mock needs an account, I tried the local mock of Postman CLI 1.90: postman mock generate produces a runnable mock from a collection or an OpenAPI file:
$ npx postman mock generate openapi.json -o mocks/bookstore --port 4510
✓ mock generated from spec
name: Bookstore.Api | v1
...
source: spec ../../openapi.json · static
Run it with:
postman mock run mocks/bookstore/config.yaml
$ npx postman mock run mocks/bookstore/config.yaml --no-history
Mock server started at http://localhost:4510 (Bookstore.Api | v1)
POST /api/auth/login
GET /api/auth/me
GET /api/books
...
The generated default.js is an ordinary Node.js HTTP server, with an if block and an example response for each endpoint. Sending requests to the mock:
$ curl -s localhost:4510/api/orders/7
{"id":-8,"status":"string","placedAtUtc":"2012-08-30T17:44:07.022Z","items":[{"title":"string","unitPrice":0,"quantity":-536594210,"lineTotal":3823123089},{"title":"string","unitPrice":-62.81439162969,"quantity":0,"lineTotal":-51664384133.35143}],"total":0,"currency":"string"}
$ curl -si localhost:4510/api/books/123 -H "x-mock-response-code: 404" | head -1
HTTP/1.1 404 Not Found
$ curl -si -X POST localhost:4510/api/orders | head -1
HTTP/1.1 201 Created
The mock works, but its data is useless: a quantity of -536594210, negative prices, "status":"string". The reason is that Bookstore.Api's OpenAPI document has no examples; the mock generates random values from the schema. It also does not ask for a token: POST /api/orders without Bearer returned 201, while the real API returns 401. You cannot test the 401 flow on the Angular side against this mock. To make the mock useful, either add realistic examples to the OpenAPI document, or save example responses from the real API on the collection's requests and generate the mock from the collection, or edit the generated file by hand. For the last route, the file itself says that regenerating replaces your changes. Code-based mock alternatives (such as WireMock.Net and Prism) are left to the Integration and E2E Test series.
Native Git, Plans and What They Mean for the Team
According to Postman v12's documentation, Native Git keeps collections, environments and specs as files in a postman/ folder in the repo through "Local View" in the desktop app. The mapping lives in .postman/resources.yaml. Collections are in the Collection 3.0 format: each request is a separate *.request.yaml file. To share with the team, they are sent to the cloud side with "Push to Postman Cloud". This is Postman's answer to the first post's "a cloud collection falls behind the code" problem. But the Postman side of sharing still needs a workspace, and since March 2026 the free plan is single-user and cannot create a team. If two people work in the same workspace, the Team plan (19 dollars per user per month billed annually) is needed.
The rule I took for Bookstore: the collection lives in Git, and Postman is a client that opens it. The postman/ folder in the repo goes through review and runs in CI with the Postman CLI or Newman; a developer who wants to use Postman imports the file or opens it through Native Git. The cloud workspace only comes in when something has to live in the cloud, such as a monitor, a cloud mock or a shared Vault, and then knowing its cost.
In Production
If you keep a production environment in Postman, no secret should sit in it as a shared value. Anyone who exports the environment sees the shared values in clear in the file. Keep collections that run against production separate and take the write requests out; picking the wrong environment in the Runner means creating a real order for every row of a data file.
The automatic login in the pre-request script can be dangerous in production. The script logs in on every run; if the login endpoint has rate limiting or account lockout (protecting token endpoints is covered in the API Security series), a frequently running monitor can lock a real user's account. Use a separate service identity for monitors and CI, not a human account.
Even though scripts run in a sandbox, pm.sendRequest can send a request to any address. Before running a collection from outside, look at the collection- and folder-level scripts; sending the token in your environment to another address is a one-line job.
Common Pitfalls
Writing a token or password into the shared value. The value goes to the cloud. Use the local value, Vault or --env-var in CI; secret fields in the environment file in the repo stay empty.
Keeping test logic only in Postman. Checking a business rule like "Ordering more than available returns 409" only in a pm.test hides the rule where the team cannot see it. In Bookstore the same rule is tested in OrderingTests with WebApplicationFactory. Let the collection say the flow is up, and let the integration tests in the codebase own the business rules.
Not checking the expected values in the data file. A wrong expectation, like the last line of the CSV above, produces a run broken by the data rather than the API. Put data files through code review too.
Forgetting that collection variables survive between runs. In the app, a cartId left over from the previous run makes you send requests to a deleted cart. Reset the variables you need at the start of the flow, or start each run with a clean environment.
A Small Experiment
★☆☆ Run the collection with Newman for three iterations with -n 3 and count how many times it logs in. Then raise the 30 seconds in the accessTokenExpiresAt comparison to 899,000 milliseconds and count again.
★★☆ Add a request to the "Ordering" folder: it should read Ada's order with Grace's token and get 200, then read someone else's order with Ada's token and get 404. Add staffEmail and staffPassword to the environment for the second user; get the token into a separate collection variable with pm.sendRequest.
★★★ Make the mock useful: add a realistic example for OrderView to Bookstore.Api's OpenAPI document with a schema transformer (The Hobbit, 2 copies, 659.80 TRY), run postman mock generate again and see that GET /api/orders/7 now returns a meaningful order.
We can put a Postman collection into the repo, but diffing the JSON by hand is hard: a changed test line shows up as quoted lines in a long exec array. In the next post we move to a client designed around files from the start: Bruno, OpenCollection YAML and a collection that runs in GitHub Actions with the CLI.