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

API Test Cases: 34 Positive and Negative Cases, Run Against a Real API

A catalogue of API test cases where every case was sent to a real API. 34 requests to Restful Booker, grouped by endpoint, with what a well-behaved API should return and what this one did, followed by a Playwright spec that checks the contract and records the known defects.

QA Vibes EditorialPublished Updated 12 minTested with Playwright 1.63.0Revision history ↓

Key takeaways

  • Negative cases find most bugs: of 9 defects we found in 34 requests to Restful Booker, 7 came from invalid or repeated input.
  • A 200 doesn't mean it worked. The API answered 200 while storing a price of "abc" as null and "no" as true.
  • Read the stored record back after a create. The status looked fine for an impossible date; the record said "0NaN-aN-aN".
  • Record a known bug as a test that asserts the right behavior with test.fail(). It passes while the bug is there and fails the day it's fixed.
  • On a shared practice API, delete everything your tests create.
Contents (12 sections)

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.

Sources and further reading

Tools mentioned

PlaywrightUI AutomationOpen source

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.
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.