Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

API Testing for Beginners: Postman, Newman, and Playwright on a Real API

A hands-on introduction to API testing: explore an API with curl, write a Postman collection and run it with Newman, then write the same checks in Playwright. Uses a public practice API whose real responses include a few surprises.

QA Vibes EditorialPublished Updated 9 minTested with Newman 6.2.2, Playwright 1.63.0, curl 8.21.0Revision history ↓

Key takeaways

  • Send every request by hand first and write down the real response: this API returns 200 for a wrong password and 500 for a missing field.
  • Check the response body, not just the status code.
  • Save requests in a Postman collection and run it in CI with Newman, which exits with code 1 on any failed assertion.
  • Move the checks that must never break into code, next to your other tests.
Contents (10 sections)

Introduction

An API test sends a request and checks the response: the status code, the body, and sometimes the headers or the time it took. There is no browser, no layout, and no waiting for buttons, so API tests are fast and rarely flaky. They are usually the best place to test business rules.

This guide uses Restful Booker, a free practice API for hotel bookings. It is public and shared, so anyone can create and delete bookings on it. You'll test it three ways:

  1. curl, to explore it by hand.
  2. Postman and Newman, to save the requests as a collection and run them from the command line.
  3. Playwright, to write the same checks as code.

Every request and output below comes from real runs in September 2026.

What an API test checks

For each endpoint, ask five questions:

Question Example check
Did it succeed or fail the right way? Status code is 200, 201, 403, or 404 as expected
Is the data right? The booking you read back equals the booking you sent
Is it protected? Updating without a token is rejected
Does it handle bad input? A request with missing fields gets a clear error
Is it fast enough? Response time stays under an agreed limit

Beginners usually stop at the first row. The bugs live in the other four.

Explore the API with curl

Before writing tests, send a few requests by hand and write down what really comes back. Create a booking:

curl -X POST https://restful-booker.herokuapp.com/booking \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"firstname":"Ada","lastname":"Lovelace","totalprice":120,"depositpaid":true,"bookingdates":{"checkin":"2026-10-01","checkout":"2026-10-03"},"additionalneeds":"Breakfast"}'

The response contains the new ID and echoes the booking:

{
  "bookingid": 1710,
  "booking": {
    "firstname": "Ada",
    "lastname": "Lovelace",
    "totalprice": 120,
    "depositpaid": true,
    "bookingdates": { "checkin": "2026-10-01", "checkout": "2026-10-03" },
    "additionalneeds": "Breakfast"
  }
}

Updating or deleting needs a token from POST /auth, sent back as a cookie: Cookie: token=<value>.

What the API really returns

We sent each of these requests and recorded the status code:

Request Status we got What many REST guides would expect
POST /booking (valid) 200 OK 201 Created
GET /booking/{id} 200 OK 200 OK
PUT /booking/{id} without a token 403 Forbidden 401 or 403
PATCH /booking/{id} with a token 200 OK 200 OK
DELETE /booking/{id} with a token 201 Created 200 or 204
GET /booking/{id} after delete 404 Not Found 404 Not Found
POST /auth with a wrong password 200 OK, body {"reason":"Bad credentials"} 401 Unauthorized
POST /booking with only firstname 500 Internal Server Error 400 Bad Request
GET /ping 201 Created 200 OK

This table is the most useful thing in the guide. Testing against assumptions ("create returns 201") produces tests that fail for the wrong reason. Testing against observed behavior produces tests that pass today and tell you the moment behavior changes.

The bold rows are worth a conversation. A 500 for a missing field means the server crashed instead of validating input, and a 200 for bad credentials means a client that only checks status codes would think it logged in. On a real product, each of those is a bug report. On a practice API, they're a reminder to check the body, not just the status. For the full list of positive and negative cases against this API, including the nine defects it has, see 34 API test cases, run against a real API.

Save the requests as a Postman collection

A collection is a list of requests plus test scripts that run after each response. This one creates a booking, updates it, and deletes it, passing the token and ID between requests with collection variables. Save it as restful-booker.postman_collection.json, or rebuild it in the Postman app and export it:

{
  "info": {
    "name": "Restful Booker smoke",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "variable": [{ "key": "baseUrl", "value": "https://restful-booker.herokuapp.com" }],
  "item": [
    {
      "name": "Get token",
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/auth",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": { "mode": "raw", "raw": "{\"username\": \"admin\", \"password\": \"{{bookerPassword}}\"}" }
      },
      "event": [{
        "listen": "test",
        "script": { "exec": [
          "pm.test('returns a token', () => {",
          "  const body = pm.response.json();",
          "  pm.expect(body.token, JSON.stringify(body)).to.be.a('string');",
          "  pm.collectionVariables.set('token', body.token);",
          "});"
        ] }
      }]
    },
    {
      "name": "Create booking",
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/booking",
        "header": [
          { "key": "Content-Type", "value": "application/json" },
          { "key": "Accept", "value": "application/json" }
        ],
        "body": { "mode": "raw", "raw": "{\"firstname\": \"Ada\", \"lastname\": \"Lovelace\", \"totalprice\": 120, \"depositpaid\": true, \"bookingdates\": {\"checkin\": \"2026-10-01\", \"checkout\": \"2026-10-03\"}}" }
      },
      "event": [{
        "listen": "test",
        "script": { "exec": [
          "pm.test('status is 200', () => pm.response.to.have.status(200));",
          "pm.test('echoes the booking', () => {",
          "  const body = pm.response.json();",
          "  pm.expect(body.booking.lastname).to.eql('Lovelace');",
          "  pm.collectionVariables.set('bookingId', body.bookingid);",
          "});"
        ] }
      }]
    },
    {
      "name": "Update price",
      "request": {
        "method": "PATCH",
        "url": "{{baseUrl}}/booking/{{bookingId}}",
        "header": [
          { "key": "Content-Type", "value": "application/json" },
          { "key": "Accept", "value": "application/json" },
          { "key": "Cookie", "value": "token={{token}}" }
        ],
        "body": { "mode": "raw", "raw": "{\"totalprice\": 150}" }
      },
      "event": [{
        "listen": "test",
        "script": { "exec": [
          "pm.test('price is updated', () => {",
          "  pm.response.to.have.status(200);",
          "  pm.expect(pm.response.json().totalprice).to.eql(150);",
          "});"
        ] }
      }]
    },
    {
      "name": "Delete booking",
      "request": {
        "method": "DELETE",
        "url": "{{baseUrl}}/booking/{{bookingId}}",
        "header": [{ "key": "Cookie", "value": "token={{token}}" }]
      },
      "event": [{
        "listen": "test",
        "script": { "exec": [
          "pm.test('status is 201', () => pm.response.to.have.status(201));"
        ] }
      }]
    }
  ]
}

Two details make the collection reusable:

  • The password is a variable, {{bookerPassword}}, not text in the file, so the collection can go into Git.
  • The token test prints the response body when it fails (JSON.stringify(body) as the assertion message). Because a wrong password still returns 200, a status check alone would pass and the next request would fail with a confusing 403.

Run the collection with Newman

Newman is Postman's command-line runner, which is what you use in CI:

npx [email protected] run restful-booker.postman_collection.json --env-var bookerPassword=password123

Our run:

Restful Booker smoke
 
→ Get token
  POST https://restful-booker.herokuapp.com/auth [200 OK, 770B, 640ms]
  √  returns a token
 
→ Create booking
  POST https://restful-booker.herokuapp.com/booking [200 OK, 914B, 129ms]
  √  status is 200
  √  echoes the booking
 
→ Update price
  PATCH https://restful-booker.herokuapp.com/booking/5449 [200 OK, 885B, 136ms]
  √  price is updated
 
→ Delete booking
  DELETE https://restful-booker.herokuapp.com/booking/5449 [201 Created, 743B, 126ms]
  √  status is 201

The summary table reported 4 requests and 5 assertions with 0 failures, and Newman exited with code 0.

To see a failure, we ran the same collection with --env-var bookerPassword=wrong. The auth request still returned 200 OK, but the token test caught it, and the two requests that needed the token failed with 403 Forbidden:

→ Get token
  POST https://restful-booker.herokuapp.com/auth [200 OK, 783B, 626ms]
  1. returns a token
 
→ Update price
  PATCH https://restful-booker.herokuapp.com/booking/3898 [403 Forbidden, 759B, 162ms]
  2. price is updated
 
  #  failure         detail
 1.  AssertionError  returns a token
                     {"reason":"Bad credentials"}: expected undefined to be a string

Newman exited with code 1, which is what fails a CI job. The first failure message contains the response body, so the cause is visible without re-running anything. Without that message you would see only the 403s further down and start debugging the wrong request. On a real pipeline, pass the password from a secret store instead of typing it.

Write the same checks in Playwright

If your team already writes Playwright UI tests, you can test APIs in the same project with the request fixture. No browser starts, so these tests take about a second each.

import { test, expect, type APIRequestContext } from "@playwright/test";
 
test.use({ baseURL: "https://restful-booker.herokuapp.com" });
 
const booking = {
  firstname: "Ada",
  lastname: "Lovelace",
  totalprice: 120,
  depositpaid: true,
  bookingdates: { checkin: "2026-10-01", checkout: "2026-10-03" },
  additionalneeds: "Breakfast",
};
 
async function getToken(request: APIRequestContext): Promise<string> {
  const response = await request.post("/auth", {
    data: { username: "admin", password: process.env.BOOKER_PASSWORD ?? "password123" },
  });
  expect(response.ok()).toBeTruthy();
  const body = await response.json();
  return body.token;
}
 
test("create, read, update, and delete a booking", async ({ request }) => {
  // Create: this API answers 200, not the 201 most REST guides would expect.
  const created = await request.post("/booking", { data: booking });
  expect(created.status()).toBe(200);
  const { bookingid, booking: saved } = await created.json();
  expect(saved).toEqual(booking);
 
  // Read
  const read = await request.get(`/booking/${bookingid}`);
  expect(read.status()).toBe(200);
  expect(await read.json()).toEqual(booking);
 
  // Update needs a token, sent as a cookie.
  const token = await getToken(request);
  const patched = await request.patch(`/booking/${bookingid}`, {
    headers: { Cookie: `token=${token}` },
    data: { totalprice: 150 },
  });
  expect(patched.status()).toBe(200);
  expect((await patched.json()).totalprice).toBe(150);
 
  // Delete answers 201 Created. Then the booking is really gone.
  const deleted = await request.delete(`/booking/${bookingid}`, {
    headers: { Cookie: `token=${token}` },
  });
  expect(deleted.status()).toBe(201);
  expect((await request.get(`/booking/${bookingid}`)).status()).toBe(404);
});
 
test("update without a token is rejected", async ({ request }) => {
  const created = await request.post("/booking", { data: booking });
  const { bookingid } = await created.json();
 
  const response = await request.put(`/booking/${bookingid}`, { data: booking });
  expect(response.status()).toBe(403);
});
 
test("wrong password returns 200 with a reason, not 401", async ({ request }) => {
  const response = await request.post("/auth", {
    data: { username: "admin", password: "not-the-password" },
  });
  expect(response.status()).toBe(200);
  expect(await response.json()).toEqual({ reason: "Bad credentials" });
});
 
test("missing required fields cause a 500, not a 400", async ({ request }) => {
  const response = await request.post("/booking", { data: { firstname: "Ada" } });
  expect(response.status()).toBe(500);
});

Our run:

Running 4 tests using 4 workers
  ok 3 [chromium] › tests\api\booking.spec.ts:68:5 › missing required fields cause a 500, not a 400 (687ms)
  ok 4 [chromium] › tests\api\booking.spec.ts:60:5 › wrong password returns 200 with a reason, not 401 (719ms)
  ok 2 [chromium] › tests\api\booking.spec.ts:52:5 › update without a token is rejected (846ms)
  ok 1 [chromium] › tests\api\booking.spec.ts:23:5 › create, read, update, and delete a booking (1.4s)
 
  4 passed (3.0s)

The last two tests are named after the behavior they pin down. If someone fixes the API to return 401 and 400, those tests fail, and the failure message tells the reader exactly which documented quirk changed. That's better than a test that silently stops matching reality.

Postman or code?

Postman + Newman Playwright (or another code framework)
Getting started Click-to-build requests, no code needed Needs a project and some TypeScript
Reviewing changes Collection JSON diffs are hard to read Normal code review
Reusing logic Scripts and variables per collection Functions, fixtures, and types
Mixing with UI tests Separate tool Same runner, same report
Sharing with non-developers Easy: import the collection Harder

A common, sensible split: use Postman to explore and share requests, and keep the checks that run on every pull request in code.

Beginner checklist

  • Every request in the suite was sent by hand first, and the real response was written down.
  • Tests check the body, not only the status code.
  • Each protected endpoint has a test without credentials.
  • At least one test sends bad input.
  • Passwords and tokens come from variables or environment variables, never the file.
  • Tests create their own data instead of relying on records someone else made. See test data management.
  • The suite runs in CI and fails the build on any failed assertion.

Conclusion

Start with curl and a notebook: send each request, record what comes back, and treat surprises as questions for the team. Then capture the requests in a Postman collection, run it with Newman, and move the checks that must never break into code next to your other tests. The practice API in this guide returns 200 for a wrong password and 500 for a missing field, a good reminder that the status code alone never tells the whole story.

Sources and further reading

Tools mentioned

PostmanAPI TestingFree plan
NewmanAPI TestingOpen source
PlaywrightUI AutomationOpen source
InsomniaAPI TestingOpen source
GitHub ActionsCI/CDFree plan

Links go to each tool’s official site. How we choose and link tools

Revision history

Updated source links that had moved: the pages still exist, at new addresses.
Linked the full list of 34 positive and negative test cases for the same API.
Rewritten around the public Restful Booker API, with a Postman collection, Newman output, and Playwright tests taken from real runs.
Revised during a site-wide content audit.
First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Practise below the UI

SQL lab

Write SQL checks against a shop database with seeded data bugs, checked in your browser.