Introduction
Most lists of API test cases were never sent to an API. This one was. Every case below is a real request to Restful Booker, a public practice API for hotel bookings, sent on 21 September 2026, with the status and body it returned.
The list is organised by endpoint, and each case says what a well-behaved API should return next to what this one did. Where they differ, that's either a defect or a design choice you'd want written down. Then 16 of the cases become a Playwright spec: ten that check the contract, and six that record known defects in a way that tells you when they're fixed.
If you're new to API testing or want the same requests in Postman and Newman, start with the API testing guide. This article is the case list.
What to check for every request
For each case, look at more than the status code:
- Status: the number, and whether it's in the right family (2xx success, 4xx the client's fault, 5xx the server's fault).
- Body: the fields and values you expect, and no error hidden inside a success.
- Stored state: read the record back. A create can answer 200 and still save something else.
- Headers: at least
Content-Type, so the client parses the body the right way.
The last two are where most of the defects below were found.
Authentication: POST /auth
| # | Case | Request | A well-behaved API | Restful Booker |
|---|---|---|---|---|
| 1 | Valid credentials | admin / password123 |
200 with a token | 200, {"token":"…"} |
| 2 | Wrong password | admin / nope |
401 | 200, {"reason":"Bad credentials"} |
| 3 | Empty body | {} |
400 or 401 | 200, {"reason":"Bad credentials"} |
Cases 2 and 3 were already in the API testing guide, and they're the reason to check the body: a client that only reads the status thinks it logged in.
Create: POST /booking
A valid booking has a first and last name, a total price, whether a deposit was paid, check-in and check-out dates, and optional additional needs.
| # | Case | Request | A well-behaved API | Restful Booker |
|---|---|---|---|---|
| 4 | Valid booking | All fields | 201 with the new id | 200 with bookingid and the booking |
| 5 | Price isn't a number | totalprice: "abc" |
400 | 200, and the price is stored as null |
| 6 | Price has cents | totalprice: 1.5 |
Store 1.5, or 400 if prices are whole | 200, and the price is stored as 1 |
| 7 | Negative price | totalprice: -5 |
400, unless refunds are bookings | 200, stored as -5 |
| 8 | Check-out before check-in | checkin: 2026-10-05, checkout: 2026-10-01 |
400 | 200, stored as sent |
| 9 | A date that doesn't exist | checkin: 2026-13-45 |
400 | 200, and the check-in is stored as "0NaN-aN-aN" |
| 10 | Empty first name | firstname: "" |
400 | 200, stored as "" |
| 11 | Missing required field | No lastname |
400 | 500 Internal Server Error |
| 12 | Deposit as text | depositpaid: "yes" |
400 | 200, stored as true |
| 13 | Deposit as the text "no" | depositpaid: "no" |
400 | 200, stored as true |
| 14 | Unknown extra field | admin: true |
Ignore it, or 400 | 200, field ignored (not stored) |
| 15 | Broken JSON | {"firstname": "Ada", |
400 | 400 Bad Request |
| 16 | No Content-Type header |
Valid JSON, no header | 400 or 415 | 500 Internal Server Error |
| 17 | Ask for XML | Accept: application/xml |
XML, labelled application/xml |
200 with XML, labelled text/html |
This is where the negative cases pay off. Case 13 is the one to remember: the API turns the text "no" into true, so a guest who didn't pay a deposit is recorded as having paid one. Nothing about the response looked wrong; you only see it by reading the record back. Cases 5, 6, and 9 are the same kind of bug: the request is accepted, and something other than what was sent is stored.
Cases 7, 10, and 14 aren't necessarily defects. Whether a negative price or an empty name is allowed is a rule someone should decide; test the rule once it's written down.
Read: GET /booking and GET /booking/{id}
| # | Case | Request | A well-behaved API | Restful Booker |
|---|---|---|---|---|
| 18 | Existing booking | GET /booking/{id} |
200, the saved booking | 200, the saved booking |
| 19 | Id that doesn't exist | GET /booking/999999999 |
404 | 404 Not Found |
| 20 | Id that isn't a number | GET /booking/abc |
400 or 404 | 404 Not Found |
| 21 | Search by name | ?firstname=Ada&lastname=Lovelace |
200, the matching ids | 200, a list of {"bookingid": …} |
| 22 | Search with no match | ?firstname=ZzzNobody123 |
200, an empty list | 200, [] |
Case 21 returns every booking with that name, and on a shared API that can include bookings other people created. Search by something your test made unique, and check that your id is in the list rather than that the list has exactly one entry.
Update: PUT and PATCH /booking/{id}
| # | Case | Request | A well-behaved API | Restful Booker |
|---|---|---|---|---|
| 23 | PUT without a token | No Cookie header |
401 or 403 | 403 Forbidden |
| 24 | PUT with a made-up token | Cookie: token=abc123 |
401 or 403 | 403 Forbidden |
| 25 | PUT with only one field | {"firstname": "Grace"} with a token |
400: PUT replaces the whole record | 400 Bad Request |
| 26 | PUT with the whole booking | All fields, with a token | 200, the updated booking | 200, the updated booking |
| 27 | PATCH one field | {"lastname": "Hopper"} with Basic auth |
200, only that field changed | 200, only that field changed |
Restful Booker accepts either a token cookie or Basic authentication with the same credentials; case 27 used Basic.
Delete: DELETE /booking/{id}
| # | Case | Request | A well-behaved API | Restful Booker |
|---|---|---|---|---|
| 28 | Without a token | No Cookie header |
401 or 403 | 403 Forbidden |
| 29 | With a token | Cookie: token=… |
200 or 204 | 201 Created |
| 30 | Read it after deleting | GET /booking/{id} |
404 | 404 Not Found |
| 31 | Delete it again | Same request as case 29 | 404 | 405 Method Not Allowed |
The rest of the API
| # | Case | Request | A well-behaved API | Restful Booker |
|---|---|---|---|---|
| 32 | A method the path doesn't support | POST /booking/{id} |
405 | 404 Not Found |
| 33 | A path that doesn't exist | GET /bookings |
404 | 404 Not Found |
| 34 | Health check | GET /ping |
200 | 201 Created |
The bold results are the ones that differ from what a well-behaved API returns. Nine of them are defects by any definition: cases 5, 6, 8, 9, 11, 13, 16, 17, and 31. The rest (like 201 for a delete) are unusual choices a client has to know about.
Automate the contract and the known defects
The spec below turns 16 of these cases into tests. The first ten check behavior the API gets right. The last six assert what the API should do and are marked with test.fail(): each passes while the defect is there, and fails the day someone fixes it, which is your cue to delete the marker. That's more useful than a skipped test, which says nothing either way.
The spec creates its own bookings and deletes them all at the end, because everyone practising on Restful Booker shares the same data.
Save this as playwright.config.ts:
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
reporter: "list",
use: {
baseURL: "https://restful-booker.herokuapp.com",
extraHTTPHeaders: { Accept: "application/json" },
},
});And this as tests/booking.spec.ts:
import { test, expect, type APIRequestContext } from "@playwright/test";
// Restful Booker publishes these credentials in its own documentation.
const USERNAME = process.env.BOOKER_USERNAME ?? "admin";
const PASSWORD = process.env.BOOKER_PASSWORD ?? "password123";
const booking = {
firstname: "Ada",
lastname: "Lovelace",
totalprice: 150,
depositpaid: true,
bookingdates: { checkin: "2026-10-01", checkout: "2026-10-05" },
additionalneeds: "Breakfast",
};
// Everything this file creates is deleted afterwards: the API is shared with everyone practising on it.
const created: number[] = [];
let token = "";
async function create(request: APIRequestContext, data: unknown) {
const response = await request.post("/booking", { data });
if (response.ok()) created.push((await response.json()).bookingid);
return response;
}
const withToken = () => ({ Cookie: `token=${token}` });
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: "https://restful-booker.herokuapp.com" });
token = (await (await api.post("/auth", { data: { username: USERNAME, password: PASSWORD } })).json()).token;
await api.dispose();
});
test.afterAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: "https://restful-booker.herokuapp.com" });
for (const id of created) await api.delete(`/booking/${id}`, { headers: withToken() });
await api.dispose();
});
test.describe("contract", () => {
test("valid credentials return a token", () => {
expect(token).toMatch(/^\w+$/);
});
test("wrong credentials return a reason, not a token", async ({ request }) => {
const response = await request.post("/auth", { data: { username: USERNAME, password: "wrong" } });
// The status is 200 even here, so the body is what tells you it failed.
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ reason: "Bad credentials" });
});
test("a valid booking is created and can be read back", async ({ request }) => {
const response = await create(request, booking);
expect(response.status()).toBe(200);
const { bookingid, booking: saved } = await response.json();
expect(saved).toEqual(booking);
const read = await request.get(`/booking/${bookingid}`);
expect(await read.json()).toEqual(booking);
});
test("a booking can be found by name", async ({ request }) => {
const { bookingid } = await (await create(request, { ...booking, firstname: "Ida", lastname: "Rhodes" })).json();
const found = await request.get("/booking", { params: { firstname: "Ida", lastname: "Rhodes" } });
expect(await found.json()).toContainEqual({ bookingid });
});
test("a booking that doesn't exist is 404", async ({ request }) => {
expect((await request.get("/booking/999999999")).status()).toBe(404);
});
test("broken JSON is 400", async ({ request }) => {
const response = await request.post("/booking", {
headers: { "Content-Type": "application/json" },
data: '{"firstname": "Ada",',
});
expect(response.status()).toBe(400);
});
test("changes need a token", async ({ request }) => {
const { bookingid } = await (await create(request, booking)).json();
expect((await request.put(`/booking/${bookingid}`, { data: booking })).status()).toBe(403);
expect((await request.put(`/booking/${bookingid}`, { data: booking, headers: { Cookie: "token=abc123" } })).status()).toBe(403);
expect((await request.delete(`/booking/${bookingid}`)).status()).toBe(403);
});
test("PUT replaces the whole booking, so a partial body is 400", async ({ request }) => {
const { bookingid } = await (await create(request, booking)).json();
const response = await request.put(`/booking/${bookingid}`, { data: { firstname: "Grace" }, headers: withToken() });
expect(response.status()).toBe(400);
});
test("PATCH changes one field and leaves the rest", async ({ request }) => {
const { bookingid } = await (await create(request, booking)).json();
const response = await request.patch(`/booking/${bookingid}`, { data: { lastname: "Hopper" }, headers: withToken() });
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ ...booking, lastname: "Hopper" });
});
test("a deleted booking is gone", async ({ request }) => {
const { bookingid } = await (await create(request, booking)).json();
expect((await request.delete(`/booking/${bookingid}`, { headers: withToken() })).status()).toBe(201);
expect((await request.get(`/booking/${bookingid}`)).status()).toBe(404);
});
});
// Each of these asserts what the API should do. test.fail() records that today it doesn't:
// the test passes while the bug is there, and fails the day it is fixed, so you notice.
test.describe("known defects", () => {
const invalid = [
{ title: "a price that isn't a number", change: { totalprice: "abc" } },
{ title: "a check-out before the check-in", change: { bookingdates: { checkin: "2026-10-05", checkout: "2026-10-01" } } },
{ title: "a date that doesn't exist", change: { bookingdates: { checkin: "2026-13-45", checkout: "2026-10-05" } } },
];
for (const { title, change } of invalid) {
test(`rejects ${title} with 400`, async ({ request }) => {
test.fail(true, "Restful Booker accepts it with 200");
expect((await create(request, { ...booking, ...change })).status()).toBe(400);
});
}
test("a missing required field is 400, not a crash", async ({ request }) => {
test.fail(true, "Restful Booker answers 500 Internal Server Error");
// JSON leaves out a field whose value is undefined, so the request has no lastname at all.
expect((await create(request, { ...booking, lastname: undefined })).status()).toBe(400);
});
test("an XML response says it is XML", async ({ request }) => {
test.fail(true, "Restful Booker labels its XML text/html");
const response = await request.post("/booking", { data: booking, headers: { Accept: "application/xml" } });
const id = (await response.text()).match(/<bookingid>(\d+)<\/bookingid>/)?.[1];
if (id) created.push(Number(id));
expect(response.headers()["content-type"]).toContain("application/xml");
});
test("deleting a booking twice is 404 the second time", async ({ request }) => {
test.fail(true, "Restful Booker answers 405 Method Not Allowed");
const { bookingid } = await (await create(request, booking)).json();
await request.delete(`/booking/${bookingid}`, { headers: withToken() });
expect((await request.delete(`/booking/${bookingid}`, { headers: withToken() })).status()).toBe(404);
});
});Run it with npx playwright test. Our run, on Playwright 1.63.0:
Running 16 tests using 1 worker
ok 1 tests\booking.spec.ts:41:7 › contract › valid credentials return a token (8ms)
ok 2 tests\booking.spec.ts:45:7 › contract › wrong credentials return a reason, not a token (532ms)
ok 3 tests\booking.spec.ts:52:7 › contract › a valid booking is created and can be read back (652ms)
ok 4 tests\booking.spec.ts:61:7 › contract › a booking can be found by name (661ms)
ok 5 tests\booking.spec.ts:67:7 › contract › a booking that doesn't exist is 404 (539ms)
ok 6 tests\booking.spec.ts:71:7 › contract › broken JSON is 400 (491ms)
ok 7 tests\booking.spec.ts:79:7 › contract › changes need a token (877ms)
ok 8 tests\booking.spec.ts:86:7 › contract › PUT replaces the whole booking, so a partial body is 400 (1.1s)
ok 9 tests\booking.spec.ts:92:7 › contract › PATCH changes one field and leaves the rest (1.1s)
ok 10 tests\booking.spec.ts:99:7 › contract › a deleted booking is gone (763ms)
x 11 tests\booking.spec.ts:116:9 › known defects › rejects a price that isn't a number with 400 (522ms)
x 12 tests\booking.spec.ts:116:9 › known defects › rejects a check-out before the check-in with 400 (497ms)
x 13 tests\booking.spec.ts:116:9 › known defects › rejects a date that doesn't exist with 400 (537ms)
x 14 tests\booking.spec.ts:122:7 › known defects › a missing required field is 400, not a crash (564ms)
x 15 tests\booking.spec.ts:128:7 › known defects › an XML response says it is XML (508ms)
x 16 tests\booking.spec.ts:136:7 › known defects › deleting a booking twice is 404 the second time (717ms)
16 passed (14.3s)The six lines marked x are the expected failures, counted among the 16 passed. We ran the whole file twice with the same result, and then checked that searching for the names it used returned no bookings.
One caveat about test.fail(): it accepts any failure, including a network error. When one of these tests changes state, read why. On our run, each failed for the stated reason: the first three got 200 where they expected 400, the missing field got 500, the XML response was labelled text/html; charset=utf-8, and the second delete got 405.
Exercise
Add the cases the spec doesn't cover yet to the known defects block: the text "no" stored as true (case 13), and a request without a Content-Type header (case 16). Assert what the API should do, and mark each with test.fail() and a reason. Then run the file and check that both appear with an x.
Conclusion
Positive cases prove the API does its job; negative cases find most of where it doesn't. On Restful Booker, 7 of the 9 defects came from invalid or repeated input, the other two from valid requests (a price with cents, and asking for XML), and 6 of the 9 answered 200. Check the status, the body, the stored record, and the headers, write down the rules the API should follow, and record known defects as tests that will tell you when they change.