API Testing with Postman: Tests, Variables, Automation and Newman
Learn Postman API testing on a real public API: collections, environments, pm.test assertions, chained requests, data-driven runs and automated runs in CI with Newman.
Prerequisites
- •Postman desktop app or web app (free account)
- •Basic understanding of HTTP methods and JSON
- •Node.js installed for the Newman section
What You'll Learn
- Organise requests into collections and switch targets with environments and variables
- Write assertions with pm.test, pm.expect and JSON schema validation
- Chain requests by saving response values, and use pre-request scripts
- Run data-driven tests and automate the collection in CI with Newman and GitHub Actions
Postman is the quickest way to go from "I think the API works" to a repeatable suite of API tests that runs on every commit. This tutorial tests a real public REST API, JSONPlaceholder at https://jsonplaceholder.typicode.com, so you can follow along without building a backend. You will create a collection, use environments and variables, write assertions, chain requests, run data-driven tests, and finally run everything in CI with Newman.
JSONPlaceholder is a free fake API: it supports GET, POST, PUT, PATCH and DELETE on resources like /posts, /users and /comments, returns realistic JSON, but does not actually save anything. That makes it a safe sample API for testing in Postman.
Step 1: create a collection and an environment
A collection is a folder of saved requests plus the tests and scripts attached to them. An environment is a set of variables you can switch between, such as local, staging and production.
JSONPlaceholder.public, and add a variable baseUrl with the value https://jsonplaceholder.typicode.com. Save it and select it in the environment dropdown at the top right.GET, URL {{baseUrl}}/posts/1. Click Send.You should get a 200 OK with a JSON post that has userId, id, title and body. The double curly braces are Postman variables, resolved from the active environment. Switching the environment to point at staging is now a single dropdown change instead of editing every URL.
Variable scopes
Postman resolves a variable by looking in the narrowest scope first:
pm.variables.set.Put secrets in environment variables and mark them as secret type, so they are masked in the UI.
Step 2: write your first tests
Open the Get post request, go to the Scripts tab, choose Post-response, and paste:
pm.test("status is 200", function () {
pm.response.to.have.status(200);
});
pm.test("responds in under 1 second", function () {
pm.expect(pm.response.responseTime).to.be.below(1000);
});
pm.test("returns JSON", function () {
pm.response.to.have.header("Content-Type");
pm.expect(pm.response.headers.get("Content-Type")).to.include("application/json");
});
pm.test("post has the expected shape", function () {
const post = pm.response.json();
pm.expect(post).to.have.property("id", 1);
pm.expect(post.userId).to.be.a("number");
pm.expect(post.title).to.be.a("string").and.not.empty;
});Send the request again and open the Test Results tab: four green passes.
pm.test(name, fn) defines one named test. If anything inside throws, that test fails and the rest still run.pm.response is the response: .code, .responseTime, .headers, .json() and .text().pm.expect is the Chai assertion library, so you get readable chains like to.be.a("string"), to.include, to.have.lengthOf and to.eql.For stricter contracts, validate against a JSON schema:
const schema = {
type: "object",
required: ["userId", "id", "title", "body"],
properties: {
userId: { type: "integer" },
id: { type: "integer" },
title: { type: "string" },
body: { type: "string" }
}
};
pm.test("matches the post schema", function () {
pm.response.to.have.jsonSchema(schema);
});Schema tests catch the bugs that break clients, such as a field changing from number to string, without you writing an assertion per field. Paste a response into the JSON formatter if you want to read it while writing the schema.
Step 3: test a POST request
Add a request Create post: method POST, URL {{baseUrl}}/posts, body raw > JSON:
{
"title": "Testing with Postman",
"body": "Created from a Postman test",
"userId": 1
}Post-response script:
pm.test("status is 201 Created", function () {
pm.response.to.have.status(201);
});
pm.test("echoes the payload and assigns an id", function () {
const sent = JSON.parse(pm.request.body.raw);
const created = pm.response.json();
pm.expect(created.title).to.eql(sent.title);
pm.expect(created.userId).to.eql(sent.userId);
pm.expect(created).to.have.property("id");
pm.collectionVariables.set("createdPostId", created.id);
});JSONPlaceholder returns 201 with the new resource and an id of 101. Comparing against pm.request.body keeps the test correct even when you change the payload.
Step 4: chain requests
Real API tests are flows: log in, create something, read it back. The pattern is always the same: extract a value in one request's post-response script, store it in a variable, use it in the next request.
Add a request Get user, GET {{baseUrl}}/users/1, with this script:
pm.test("user found", function () {
pm.response.to.have.status(200);
const user = pm.response.json();
pm.collectionVariables.set("userId", user.id);
pm.collectionVariables.set("userEmail", user.email);
});Then add Get posts for user, GET {{baseUrl}}/posts?userId={{userId}}:
pm.test("every post belongs to the user", function () {
const posts = pm.response.json();
const userId = Number(pm.collectionVariables.get("userId"));
pm.expect(posts).to.have.lengthOf(10);
posts.forEach(function (post) {
pm.expect(post.userId).to.eql(userId);
});
});Saving an auth token
JSONPlaceholder has no authentication, but against your own API the login request's script looks like this:
pm.test("login succeeded", function () {
pm.response.to.have.status(200);
pm.environment.set("accessToken", pm.response.json().accessToken);
});Then set the collection's Authorization tab to Bearer Token with the value {{accessToken}}, and every request inherits it. To inspect what the token contains, paste it into the JWT decoder.
Step 5: pre-request scripts
Pre-request scripts run before the request is sent. Use them to generate data or refresh a token. On the Create post request, add a Pre-request script:
pm.variables.set("runId", Date.now().toString());and change the body title to "Testing with Postman {{runId}}". Each run now sends a unique title, which matters when your real API rejects duplicates.
At collection level, a pre-request script runs before every request. That is the place for logic like "if accessToken is empty, call the login endpoint with pm.sendRequest and store the result".
Step 6: data-driven runs with the Collection Runner
Create posts.csv:
postId,expectedUserId
1,1
11,2
21,3
95,10Duplicate Get post as Get post (data-driven), set the URL to {{baseUrl}}/posts/{{postId}}, and use this script:
pm.test("post " + pm.iterationData.get("postId") + " has the right author", function () {
const post = pm.response.json();
pm.expect(post.userId).to.eql(Number(pm.iterationData.get("expectedUserId")));
});Click the collection's Run button, select only that request, choose Select File under Data, pick posts.csv, and run. The Collection Runner runs the request once per CSV row, with each column available as a variable and via pm.iterationData. JSONPlaceholder gives each user 10 consecutive posts, which is why post 11 belongs to user 2.
Step 7: run the collection in CI with Newman
Newman is Postman's open-source command-line collection runner. Export the collection (... next to the collection > Export, format v2.1) and the environment, and commit them to a postman/ folder in your repository, together with posts.csv. Then:
npx --yes newman run postman/jsonplaceholder.postman_collection.json \
-e postman/public.postman_environment.json \
-r cli,junit \
--reporter-junit-export results/newman.xmlUseful options:
-d posts.csv runs the collection once per data row, like the Collection Runner.--env-var "apiKey=..." injects a value at run time, which is how CI secrets get in without committing them.--bail stops at the first failing test.-n 3 repeats the run three times, handy for spotting flaky endpoints.Newman exits with a non-zero code when any test fails, which is what fails the CI job. The junit reporter writes a report most CI systems can display.
GitHub Actions workflow
Save as .github/workflows/api-tests.yml:
name: API tests
on:
push:
branches: [main]
pull_request:
jobs:
newman:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Run Postman collection
run: >
npx --yes newman run postman/jsonplaceholder.postman_collection.json
-e postman/public.postman_environment.json
--env-var "apiKey=${{ secrets.API_KEY }}"
-r cli,junit --reporter-junit-export results/newman.xml
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: newman-results
path: results/For a full pipeline that also builds and deploys, see the GitHub Actions CI/CD tutorial; this job slots in as a step after deployment to staging.
Postman CLI as an alternative
Postman also ships its own Postman CLI, which runs collections straight from your Postman workspace by ID (postman collection run after postman login) and reports results back to Postman. Choose it if your team lives in the Postman app and wants run history there; choose Newman if you want everything versioned in Git and no account dependency in CI. Check the Postman CLI docs for its current options.
Common mistakes
200 with an empty array or a wrong field type still breaks clients. Assert on the body, or use a schema.{{baseUrl}} and environment variables from day one, or moving to staging means editing every request.--env-var.
