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:
- curl, to explore it by hand.
- Postman and Newman, to save the requests as a collection and run them from the command line.
- 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=password123Our 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 201The 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 stringNewman 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.