Day 19: Playwright API Testing — Restful Booker, JSONPath, and Schema Validation
This is Day 19 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright Test after that. Framework now.
Days 1-7 were the language. Day 8 was the object model. Day 9 typed it. Days 10-16 opened the browser, found fields, saved a session, asserted, hooked, looped CSV, and put a LoginPage on disk. Day 17 opened AdvancePlaywrightFramework1x — config, pages, fixtures, reporters. Day 18 was Cucumber on the same branch: a second runner that reuses the same Page Objects. Today we leave the browser.
I am Pramod Dutta. I teach SDETs in India for a living. The week I open API testing in Playwright, someone always asks for Rest Assured. Someone else pastes request.post into every spec and copies the token by hand. Both miss the point. Playwright already has APIRequestContext. The skill is not “can I hit /booking”. The skill is who owns the HTTP — the spec, a helper, a typed client, a fixture, a schema.
This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts later in the stack. This series started at console.log. Day 19 is the first day the API gets the same layered treatment the UI already has.
All labs come from my public framework repo: AdvancePlaywrightFramework1x on branch feat-cucumber. I fetched the trees GitHub lists under src/tests/apiTests/01_restfulbooker_raw, 02_restfulbooker_apiHelper, 03_restfulbooker_fixture_e2e, 04_jsonpath_plus, and 05_ajv_schema, plus src/api/BookingApi.ts, src/utils/ApiHelper.ts, src/utils/schemaValidator.ts, and src/fixtures/booker.fixture.ts. I quote those files. I will not invent a file that is not there.
The same apiTests tree also has 06_ai_datagen. That folder is on the branch. It is not today’s lesson. I will not open it. I will not invent prompts, agents, or generated payloads for a day that is Restful Booker, JSONPath, and schema.
Classroom comments stay. BookingApi.ts says atuh, and token. ApiHelper.ts titles a block Request Modifiction and writes exmaple3. post_operation.spec.ts names a second test PUT : Verify that create booking is working fine and then posts again. I do not rename identifiers or “fix” titles to make this post prettier.
If you want the video plus project path after you finish these 21 posts, the course is here: Playwright Automation Mastery. The series hub for every day lives here: JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.
*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Contents
What you will be able to do after Day 19
By the end of this post you can:
- Tell a spec that owns every
request.postfrom a spec that only names a booking flow. - Hit Restful Booker with Playwright built-in
requestfixture — no browser, no page. - Read
basic_ping.spec.tsand say why/pingexpects 201, not 200. - Walk
crud.spec.ts: token then create then update,test.describe.serial, aBookingFlowStateobject that carriestokenandbookingId. - Wrap the same verbs in
ApiHelper—get,post,put,patch,delete,isSuccess,parseJsonResponse. - Read
BookingApias the typed client:auth,createBooking,updateBooking,patchBooking,deleteBooking,getBookingResponse. - Admit that token generation lives in a fixture —
bookerTokeninsrc/fixtures/booker.fixture.ts— and that the e2e spec never calls/authitself. - Query a booking body with
jsonpath-plus: root, child, recursive descent, wildcard, index, slice, filter. - Contract-test
POST /bookingagainstsrc/testdata/schemas/create-booking.schema.jsonwith Ajv plusajv-formats. - Run the dedicated
apiPlaywright project so Chromium does not duplicate request-only specs. - Draw the Day 19 diagram from memory: raw request -> helper -> fixture -> schema.
- Leave
06_ai_datagenclosed. That is a later layer, not this post.
That is the skill. Not the status code. The skill is who owns the request — the spec, a helper, a client Playwright injects, or a schema that refuses drift.
The labs we are actually using
Clone the framework repo and stay on feat-cucumber:
Five numbered folders under src/tests/apiTests/, plus the framework files those specs import. I fetched every blob GitHub lists in those folders. Those files, as GitHub serves them:
Day 17 already opened this repo. Stay on branch feat-cucumber. Repo: https://github.com/PramodDutta/AdvancePlaywrightFramework1x
src/tests/apiTests/01_restfulbooker_raw/ — the spec owns HTTP. Five files.
- basic_ping.spec.ts — request.get /ping, expect 201
- crud.spec.ts — test.describe.serial, token then create then update
- newcontext_api.spec.ts — request.newContext against gorest.in, isolated headers
- post_operation.spec.ts — two tests; both POST /booking (the second title says PUT)
- put_operation.spec.ts — token, create, then PUT with Cookie token
src/tests/apiTests/02_restfulbooker_apiHelper/ — verbs move into ApiHelper. Two files.
- create-booking.spec.ts — new ApiHelper(request), api.post /booking, isSuccess, attach JSON
- update-booking.spec.ts — auth, create, PUT with Cookie via the helper
src/tests/apiTests/03_restfulbooker_fixture_e2e/ — typed client plus fixture. One file.
- booking-crud.e2e.spec.ts — create, update with bookerToken, delete, GET 404
src/tests/apiTests/04_jsonpath_plus/ — query the body. Three files.
- jsonpath-queries.e2e.spec.ts — ten queries on live Restful Booker responses
- jsonpath-cheatsheet.md — operator table against store.json
- store.json — The Testing Academy Bookstore sample document
src/tests/apiTests/05_ajv_schema/ — contract. One file.
- create-booking-schema.spec.ts — static sample, live POST, deliberately broken object
Framework files the specs import
- src/utils/ApiHelper.ts — HTTP wrapper
- src/api/BookingApi.ts — Restful Booker client
- src/fixtures/booker.fixture.ts — bookingApi plus bookerToken
- src/utils/schemaValidator.ts — Ajv Draft-07 plus ajv-formats
- src/testdata/booking.data.ts — buildBooking() Faker factory
- src/testdata/schemas/create-booking.schema.json — the contract
- playwright.config.ts — dedicated api project
On the tree, not in this post: src/tests/apiTests/06_ai_datagen/. Present. Closed.
You need Node.js 18 or newer. package.json on this branch pins @playwright/test ^1.60.0, jsonpath-plus ^10.4.0, ajv ^8.20.0, ajv-formats ^3.0.1, @faker-js/faker ^8.4.1.
Why Playwright API testing is not Rest Assured with extra steps
Playwright Test ships APIRequestContext. The built-in request fixture is one of those contexts. It shares baseURL and extraHTTPHeaders from playwright.config.ts. It does not open Chromium. It does not need a page.
That is the first thing I tell a batch that spent two years in Java. You do not need a second runner to test /booking. You need a project that matches API specs and ignores them in the browser project so the same file is not executed four times.
On feat-cucumber, playwright.config.ts does exactly that. The api project testMatches src/tests/apiTests spec files. The chromium project testIgnores the same pattern. Firefox, WebKit, and mobile-chrome projects are commented out in the config I fetched.
testDir is ./src/tests. use.baseURL comes from resolveBaseURL(). When TTA_ENV=api, that function returns process.env.API_BASE_URL or https://restful-booker.herokuapp.com. The same config also sets extraHTTPHeaders Accept and Content-Type to application/json.
So a raw request.get(“/ping”) already has JSON headers and, if you set the env, a booker base URL. Layer 01 still hard-codes API_BASE_URL or the herokuapp host in several specs. I leave that as the file wrote it. Layer 02 uses relative /booking and lets baseURL do the work. That difference is the lesson, not a bug I silently fixed.
use also turns screenshot only-on-failure, video on, trace on-first-retry. API specs do not screenshot a page. They attach JSON with testInfo.attach. The TTA custom reporter still renders test.step titles and Winston lines. That is why layer 02 comments say: no browser, so no visualStep — plain test.step plus an attached body.
Restful Booker is the public practice API this framework targets. I will not invent a GraphQL client, an AuthApi.ts, or a /ping method on BookingApi. Those files are not on this branch.
| Method | Path | Auth | What the labs use it for |
|---|---|---|---|
| GET | /ping | no | health; this API returns 201 |
| POST | /auth | no | token (admin / password123) |
| GET | /booking | no | list of { bookingid } |
| GET | /booking/{id} | no | one booking; 404 after delete |
| POST | /booking | no | create |
| PUT | /booking/{id} | Cookie token | full replace |
| PATCH | /booking/{id} | Cookie token | partial (client method exists; no dedicated PATCH spec in 01-05) |
| DELETE | /booking/{id} | Cookie token | this API returns 201 on success |
Layer 01 — the spec owns the HTTP
A raw request is the honest first lab. You see the URL, the headers, the payload, the status, the JSON. You also see the pain: every spec repeats baseUrl, headers, and the token dance.
Ping first. Expect 201.
src/tests/apiTests/01_restfulbooker_raw/basic_ping.spec.ts is eight lines. It imports test and expect from @playwright/test. The test is named Ping request. It takes the built-in request fixture, calls request.get(“/ping”), logs the response object, and expects status 201.
GET /ping on Restful Booker is a health check that answers 201 Created, not 200. I have failed this in live batches because someone corrected it to 200 from memory. The file is the source of truth. If the public API changes tomorrow, the spec fails. That is the point of a ping.
Relative /ping needs baseURL. Run it under the api project with TTA_ENV=api, or the request goes to the default QA app URL and you will not get 201.
import { test, expect } from "@playwright/test";
test("Ping request", async ({ request }) => {
const responseData = await request.get("/ping");
console.log(responseData);
expect(responseData.status()).toBe(201);
});
POST create, then the title that lies
post_operation.spec.ts has two tests. Both POST /booking with Jim Brown, totalprice 111, breakfast. Both assert 200, a truthy bookingid, and echoed first and last name. The second test is titled: TC#1 @p0 – PUT : Verify that create booking is working fine.
It is not a PUT. I do not rename it. Classroom leftovers stay. The real PUT is the next file.
PUT is three steps in one test
put_operation.spec.ts is the first file that needs a token. Restful Booker PUT (and PATCH, and DELETE) wants Cookie: token=… The spec does three test.step blocks in one test:
- POST /auth with admin / password123 — store token
- POST /booking — store bookingId
- PUT /booking/{bookingId} with Cookie: token=${token} — assert firstname and lastname
baseUrl is process.env.API_BASE_URL or https://restful-booker.herokuapp.com. Headers are spelled out again. Logger lines go to Winston. This is the pattern every junior copies into the next five files. It works. It does not scale.
Serial CRUD and a state object
crud.spec.ts is the same story split across tests. Playwright runs tests in a file in parallel by default. A create that must happen before an update cannot be parallel. The file uses test.describe.serial(“Restful Booker CRUD API”).
State is a typed object, not let soup at the top of a single test. BookingFlowState has optional token and bookingId. TC#1 creates the token. TC#2 creates the booking. TC#3 updates. If token or id is missing, the update throws: Create token and create booking tests must pass before update booking. That sentence is the contract between serial tests. There is no DELETE in this file. There is no GET-after-update. Layer 03 adds those.
Interfaces in this spec (BookingDates, BookingPayload, AuthTokenResponse, CreateBookingResponse) live in the spec. Layer 03 moves the same shapes to src/api/BookingApi.ts. Watch that migration. That is the framework forming.
The payload in crud.spec.ts is firstname Pramod, lastname Dutta, totalprice 111, depositpaid true, checkin 2018-01-01, checkout 2019-01-01, additionalneeds Breakfast. The same shape shows up in put_operation.spec.ts. Raw specs copy payloads. Later layers call buildBooking().
Isolated context is a different host
newcontext_api.spec.ts is the odd file in folder 01. It does not hit Restful Booker. It builds a private APIRequestContext with request.newContext. baseURL is https://gorest.in. extraHTTPHeaders sets X-Trace-Id to demo-123. timeout is 15000. The get path is //public/v2/users/1001?page=1&per_page=10 — double slash, as the file wrote it. Expect 200. Then ctx.dispose().
Three things I say out loud in class:
- request.newContext is the static helper on the request module, not the { request } fixture. You use it when this call must not inherit baseURL or default headers from playwright.config.ts.
- The path has a double slash. I do not tidy it.
- ctx.dispose() matters. A context you created is yours to close.
I will not invent a Booker newContext lab. This is the file. It is a header-isolation demo, not a CRUD step.
Layer 02 — verbs live in ApiHelper
Open src/utils/ApiHelper.ts. The class comment says it is a simple type of class that can help you make a different type of HTTP. Under that, the classroom heading is Request Modifiction. Keep it.
ApiContext is Page | APIRequestContext. That union is why the same helper works from a UI test (page.request) or from the request fixture. getRequest() checks “request” in this.context and returns this.context.request or the context itself.
buildUrl appends URLSearchParams. The comments walk three examples, including exmaple3. If you pass params, you get url?key=value. Booking list filters use this later via BookingApi.getAllBookings.
callApi is the switch: GET, POST, PUT, DELETE, PATCH. Anything else throws Unsupported HTTP method. Convenience methods (get, post, put, delete, patch) are thin wrappers so a spec does not spell method: POST.
callApiWithRetry polls. Default pollingInterval is 5000 ms. Default retryCount is 3. Folder 02 does not call retry. I will not invent a retry spec. The method is on the class. The labs we run today use the convenience verbs.
Two status helpers live on the class. isSuccess is status >= 200 and < 300. isFailureClient is status >= 400 and < 500. There is no isFailureServer in the file. Day 17 already said that. I will not add a 5xx helper.
parseJsonResponse is response.json() as T. Type safety here is a cast, not a runtime check. Schema validation is Day 19 last layer, not this one.
Create booking through the helper
create-booking.spec.ts is the first spec that looks like a framework test. It imports ApiHelper from @utils/ApiHelper, builds a scoped logger createLogger(“create-booking”), and wraps two steps.
The spec does new ApiHelper(request), then api.post(“/booking”, payload), then expect(api.isSuccess(response)).toBe(true), then parseJsonResponse. Relative /booking. No hard-coded herokuapp URL. Payload firstname is Helper, lastname Creator, price 640, dates in 2026. After the POST, the spec attaches the body as create-booking-response, application/json.
That attachment is the API equivalent of a screenshot. The TTA reporter shows it. The file header comment is explicit: no visualStep, because visualStep screenshots a page.
Update booking through the helper
update-booking.spec.ts does the three-phase dance without test.step titles. Auth POST /auth. Create POST /booking as Before Helper. PUT /booking/{bookingid} as After Helper with headers Cookie token. Assert firstname After and price 880.
The helper did not invent a Cookie helper. The spec still passes the header. BookingApi will own that in layer 03 via authHeaders(token).
Day 17 quoted ApiHelper and stopped. Day 17 said: I did not fetch those specs today. I will not invent a booking POST body I did not open. Today I opened both specs. The bodies are Helper/Creator and Before/After Helper. Those are the files.
Layer 03 — BookingApi and the fixture that owns the token
Folder 03 has one spec and a comment block I want every SDET to read before they write another request.post(“/auth”):
Why are we going from raw to API helper to classes? What is the reason for it? First, we want a framework, and frameworks are scalable, maintainable, and reusable in nature.
That is the whole course in four lines. It is in booking-crud.e2e.spec.ts as comments. I did not write it for this post.
The typed client
src/api/BookingApi.ts opens with more classroom notes: serialize or deserialize, JSON schema validation, jsonpath-plus, auth or even JWT. Schema and JSONPath are separate files. JWT is a comment. I will not invent a JWT helper.
Types exported from this file: BookingDates (checkin, checkout), Booking (firstname, lastname, totalprice, depositpaid, bookingdates, optional additionalneeds), CreateBookingResponse (bookingid plus booking), BookingId ({ bookingid: number }), BookingFilters (optional firstname, lastname, checkin, checkout).
The constructor takes ApiContext and defaults baseUrl to https://restful-booker.herokuapp.com. It constructs new ApiHelper(context). So the client uses the helper. It does not replace it.
authHeaders is the one place Cookie is built. It spreads JSON_HEADERS and sets Cookie to token=${token}. The classroom comment above auth() is “atuh, and token”. Keep it.
Methods, as the file implements them:
- getAllBookings(filters?) — GET /booking, throws if not success, returns BookingId[]
- getBooking(id) — GET /booking/{id}, throws if not success, parsed booking
- getBookingResponse(id) — GET /booking/{id}, raw APIResponse so a spec can assert 404
- auth(username?, password?) — POST /auth, defaults admin / password123, throws if no token
- createBooking(payload) — POST /booking, no auth
- updateBooking(id, payload, token) — PUT, Cookie
- patchBooking(id, partial, token) — PATCH, Cookie; no spec in 01-05 calls this
- deleteBooking(id, token) — DELETE, returns response.status(), does not throw on non-2xx
getBookingResponse exists because DELETE on this API is proven by a follow-up GET that must be 404. If getBooking always threw, the e2e could not assert the ghost. That split is design, not duplication.
deleteBooking returns the status number. Restful Booker answers 201 on a successful delete. Layer 03 asserts toBe(201). If you correct that to 204 from RFC memory, the spec fails. The public API is the contract.
patchBooking is on the class. There is no patch.spec.ts in folders 01-05. I say that once and I move on.
The fixture
src/fixtures/booker.fixture.ts is short. Read it whole. It imports test as base and expect from @playwright/test, and BookingApi from ../api/BookingApi. BookerFixtures is { bookingApi: BookingApi; bookerToken: string }. Then base.extend.
bookingApi depends on the built-in request. One client per test. Fresh. new BookingApi(request) inside the fixture factory, then await use(client).
bookerToken depends on bookingApi. It calls bookingApi.auth() — POST /auth — and hands the string to the test. The comment in the file is the requirement: token generation lives in a fixture.
export const test = base.extend<BookerFixtures>({
bookingApi: async ({ request }, use) => {
await use(new BookingApi(request));
},
bookerToken: async ({ bookingApi }, use) => {
const token = await bookingApi.auth();
await use(token);
},
});
Day 16 in the fundamentals repo had tests/21_Fixture as a skipped placeholder. Day 17 opened this framework UI fixtures in test-base.ts. Today the same test.extend pattern serves an API client and a token. If you still write POST /auth in the spec after this file exists, you are back in layer 01 on purpose. Do not do that in layer 03.
The e2e lifecycle
booking-crud.e2e.spec.ts imports test and expect from @fixtures/booker.fixture, not from @playwright/test. That one import is how the fixtures arrive.
It also imports buildBooking from @testdata/booking.data. That factory lives in src/testdata/booking.data.ts. Faker v8, pinned because v9/v10 are ESM-only. buildBooking(overrides) fills firstname, lastname, totalprice, depositpaid, checkin 2026-02-01, a checkout from faker.date.soon, and a random additional need. Overrides win via spread.
Three serial tests, tag @e2e @P0, describe title Level 3 – Booking lifecycle (token from fixture):
- create a booking — bookingApi.createBooking(buildBooking({ firstname: E2E, lastname: Journey })). Assert id > 0, firstname E2E. Attach JSON as created-booking. Store bookingId in a let for the serial describe.
- update the booking — the test args are { bookingApi, bookerToken }. No /auth in the spec. PUT with lastname Updated, price 950. Then GET the same id and assert the lastname persisted. Attach updated-booking.
- delete the booking and confirm it is gone — deleteBooking expect 201. getBookingResponse expect 404.
That is a lifecycle. Create, mutate, read, delete, prove absence. The spec names the story. The client owns verbs. The fixture owns the token. The factory owns the payload.
Layer 04 — JSONPath reads the body
Field-by-field expect(body.booking.firstname) works until the payload is an array of fifty bookings and you need every id or only ids greater than 0. That is what jsonpath-plus is for.
package.json pins jsonpath-plus ^10.4.0. The import is named: import { JSONPath } from “jsonpath-plus”. Every query returns an array. A single match is still [value]. You take [0]. The cheat sheet documents { wrap: false } if you want the scalar. The e2e spec does not use wrap: false. It indexes.
The live booking queries
jsonpath-queries.e2e.spec.ts is a serial describe. It uses the same booker.fixture and buildBooking. Four tests. Ten numbered queries in the comments.
Test 1 — create and read fields. Payload firstname Json, lastname Path, price 777, breakfast.
- $.booking.firstname -> Json
- $.booking.bookingdates.checkin -> 2026-02-01 (that date is the factory default, not a JSONPath trick)
- $.bookingid -> number > 0
No body.booking.bookingdates.checkin chaining. One path.
const firstname = JSONPath({ path: "$.booking.firstname", json: body })[0];
expect(firstname).toBe("Json");
const checkin = JSONPath({ path: "$.booking.bookingdates.checkin", json: body })[0];
expect(checkin).toBe("2026-02-01");
Test 2 — wildcard and recursive descent.
- $.booking.* — every direct child value of booking. The spec asserts the array contains Wild and 540.
- $..totalprice — totalprice at any depth. Expect [540].
- $..bookingdates — the dates object, wherever it sits. Assert checkin and checkout keys.
A single dot is a direct child. Two dots is find this key anywhere. I write that on the board every batch.
Test 3 — array index, slice, filter on GET /booking. getAllBookings() returns [{ bookingid }, …].
- $[0].bookingid — first id, type number
- $[-1:].bookingid — last element via slice; take [0]
- $[*].bookingid — every id; length matches the list; every value is an integer
- $[?(@.bookingid > 0)] — filter. @ is the current item. Expect every remaining object has bookingid > 0
Test 4 — cleanup. deleteBooking(bookingId, bookerToken) expect 201. The id from test 1. Serial describe, same let bookingId.
The cheat sheet and store.json
jsonpath-cheatsheet.md is the operator table. Examples run against store.json, which is The Testing Academy Bookstore in Bengaluru: five books (including one titled Mastering Playwright by Pramod Dutta), a red bicycle, three electronics, three employees, a company.departments tree, and expensiveThreshold 10.
I am not going to paste the whole JSON. The file is at src/tests/apiTests/04_jsonpath_plus/store.json. The e2e spec you run today queries Restful Booker, not store.json. The cheat sheet uses the store document as the teaching fixture. Both files are real. Do not invent a store.spec.ts. There is not one.
Core syntax from the cheat sheet, the part I make people recite:
- $ root
- @ current node in a filter
- . or [name] direct child
- .. recursive descent
- * wildcard
- [n] index
- [n1,n2] union
- [start:end:step] slice, end exclusive
- [?(…)] filter
- [(expr)] script index, for example last item
Filters are JavaScript-like: ===, !==, <, &&, ||, =~ for regex. Examples in the cheat sheet include $.store.book[?(@.price < 10)], $.employees[?(@.role === SDET && @.salary > 90000)], and a filter that compares against the document root: $.store.book[?(@.price < $.expensiveThreshold)].
resultType path returns normalized paths instead of values. resultType all returns value, path, parent, parentProperty, pointer. The e2e spec uses the default value.
Layer 05 — Ajv refuses a broken contract
expect(body.booking.firstname).toBe(“Schema”) does not notice a new field, a missing lastname, or a checkin of “nope”. A JSON Schema does.
src/utils/schemaValidator.ts is a thin Ajv wrapper. Header comment says Draft-07, ajv-formats for date / email / uri, and the call shape: validateSchema(schema, body) returns { valid, errors, errorText }. Then expect(valid, errorText).toBe(true).
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
export function validateSchema(schema: AnySchema, data: unknown): SchemaResult {
const validate = ajv.compile(schema);
const valid = validate(data) as boolean;
const errors = validate.errors ?? [];
const errorText = errors
.map(e => ` • ${e.instancePath || "(root)"} ${e.message ?? ""}`)
.join("\n");
return { valid, errors, errorText };
}
The file constructs one Ajv instance: allErrors true, strict false. Then addFormats(ajv). validateSchema compiles the schema, runs it, maps errors to a multi-line errorText with instancePath or (root).
allErrors true so one bad object yields every violation, not the first. strict false so unknown keywords do not throw. ajv-formats is why format date is enforced. Without that package, Ajv 8 ignores format keywords. package.json pins ajv ^8.20.0 and ajv-formats ^3.0.1 together because formats v3 needs Ajv 8.
The schema file
src/testdata/schemas/create-booking.schema.json is Draft-07. $id points at a booker-shaped URL. additionalProperties false on the root, on booking, and on bookingdates. Required root keys: bookingid, booking. bookingid is an integer, minimum 1. booking.firstname and lastname are non-empty strings. totalprice is a number >= 0. depositpaid is boolean. bookingdates.checkin and checkout are strings with format date. additionalneeds is an optional string.
That additionalProperties false is the drift detector. An extra key the API started returning yesterday fails the contract. A missing lastname fails. A string bookingid fails. A checkin of nope fails format.
Three steps in one spec
create-booking-schema.spec.ts loads the schema with fs.readFileSync and path.join(__dirname, ../../../testdata/schemas/create-booking.schema.json). Then three steps.
Step 1 — static sample. Jim Brown, bookingid 3177, 2018-2019 dates, breakfast. This is schema sanity. If the schema file itself is wrong, you fail before you hit the network.
Step 2 — live POST. bookingApi.createBooking(buildBooking({ firstname: Schema })). Validate the real body. Attach it as validated-response. expect(valid, errorText).toBe(true) — the second argument to expect is the message you see on failure. That is why errorText is a multi-line summary.
Step 3 — negative. A broken object must be rejected. bookingid is the string not-a-number. lastname is missing. checkin is nope. expect valid toBe false. expect errors.length toBeGreaterThan 0.
If step 3 ever goes green with valid === true, the schema is not biting. I keep a negative on purpose. A contract test that only ever sees happy JSON is a decoration.
How the four layers sit on one request
Take POST /booking. Same public API. Four owners.
01 raw — request.post with a hard-coded baseUrl, headers, and payload. Spec owns URL, headers, payload, status, field asserts.
02 helper — api.post(“/booking”, payload) then api.isSuccess and api.parseJsonResponse. Spec still owns the payload and the field asserts. Verbs and status helpers moved.
03 fixture — bookingApi.createBooking(buildBooking({ firstname: E2E })). Spec owns the story. Client owns the path and the error on non-2xx. Factory owns the default dates. Token, when needed, comes from bookerToken.
04 JSONPath — same createBooking, then JSONPath({ path: “$.booking.firstname”, json: body })[0]. Spec owns the question it asks the body.
05 schema — same createBooking, then validateSchema(schema, body). Spec owns the contract file path. Ajv owns the walk.
I do not skip layers in a new batch. If you only copy layer 05, you cannot debug a 403 because you never watched a raw Cookie header. If you only stay on layer 01, you will paste /auth into the twentieth spec.
How to run today’s suite
From the repo root, branch feat-cucumber, set TTA_ENV=api and pass –project=api. Example: TTA_ENV=api playwright test src/tests/apiTests/01_restfulbooker_raw/basic_ping.spec.ts –project=api
Point Playwright at one ping file, or at a folder, or at the whole api project:
- 01_restfulbooker_raw/basic_ping.spec.ts for the 201 ping
- 01_restfulbooker_raw/ for every raw spec
- 02_restfulbooker_apiHelper/ for the helper layer
- 03_restfulbooker_fixture_e2e/ for the fixture lifecycle
- 04_jsonpath_plus/ for JSONPath
- 05_ajv_schema/ for Ajv
- –project=api with no extra path for every API spec the project matches, including 06_ai_datagen if those files exist
The api project flag matters. Without it, and without testIgnore on chromium, you can still run a path directly. The habit I want is: API specs through the API project.
The script test:p0 greps @p0. Several layer 01 titles use @p0. Layer 03 and 04 tag @P0 (capital P) in the describe. Grep is case-sensitive unless you pass a regex flag. I will not pretend test:p0 selects @P0. Read the tag as the file spelled it.
Reports: Playwright HTML, Allure, and the TTA custom reporter under tta-report/. API specs attach JSON. They do not attach a page screenshot.
Mistakes I see every batch
Expecting 200 on /ping or 204 on DELETE. This API returns 201 for both. The files assert 201. Believe the file.
Forgetting TTA_ENV=api on a relative /ping. resolveBaseURL() defaults to the QA app. A ping against the cart host is not a booker ping.
Calling /auth in a layer 03 spec. bookerToken already did. If you need a second user later, extend the fixture. Do not paste auth into the test.
Treating JSONPath as a scalar. JSONPath(…) is an array. expect(JSONPath(…)).toBe(“Json”) fails. Take [0].
Skipping ajv-formats and wondering why nope passes as a date. Format keywords are inert until addFormats(ajv) runs. Our wrapper already does that.
Asserting only happy schema. Step 3 in create-booking-schema.spec.ts exists so the schema can fail. Keep a broken object.
Inventing AuthApi.ts or a GraphQL client. src/api/ on this branch has BookingApi.ts. That is the client I fetched.
Opening 06_ai_datagen and calling it Day 19. The folder is on the tree. This post stops at Ajv.
Running API specs under chromium. testIgnore is there so you do not. Use the api project.
Fixing post_operation.spec.ts titles or atuh comments. Classroom spellings are how you find the file again during a live review.
Using getBooking to assert 404. getBooking throws on non-2xx. Use getBookingResponse.
Forgetting ctx.dispose() after request.newContext. The gorest lab closes what it opened.
Parallel CRUD. bookingId shared across tests needs test.describe.serial. Parallel workers will race an id that does not exist yet.
What Day 19 is not
It is not Rest Assured. It is not Postman. It is not a GraphQL lesson. It is not JWT. BookingApi.ts mentions JWT in a comment; there is no JWT helper.
It is not 06_ai_datagen. It is not the AI Agent Factory. It is not Cucumber. BDD lives under src/cucumber/ and was Day 18, not this post.
It is not Day 20. CLI flags, UI mode, traces as an operator workflow, GitHub Actions, CI=true retries — that is tomorrow. playwright.config.ts already reads process.env.CI for forbidOnly, retries 2, workers 4. I will not pretend that reading is a CI pipeline. The workflow file Day 18 already named, the test:report:ci script, and how you shard are Day 20.
FAQ
Does Playwright need a second tool for API testing?
No. APIRequestContext and the built-in request fixture are enough to call Restful Booker. This repo adds ApiHelper, BookingApi, booker.fixture.ts, jsonpath-plus, and Ajv so the call is reusable and the body is contracted. You do not need Rest Assured beside Playwright for these labs.
Why does Restful Booker /ping return 201?
Because that is what the public API returns, and basic_ping.spec.ts asserts toBe(201). Do not change the expect to 200 from habit.
Why does DELETE return 201 in the e2e spec?
booking-crud.e2e.spec.ts comments it: Booker returns 201 Created on a successful DELETE. bookingApi.deleteBooking returns response.status(). The spec expects 201, then GET 404.
Where does the token come from in layer 03?
src/fixtures/booker.fixture.ts. The bookerToken fixture calls bookingApi.auth() (POST /auth with default admin / password123) and injects the string. The e2e spec never posts /auth itself.
What is the difference between getBooking and getBookingResponse?
getBooking throws if the status is not 2xx and then parses JSON. getBookingResponse returns the raw APIResponse so you can assert 404 after delete. Both are in src/api/BookingApi.ts.
Which files are in 01_restfulbooker_raw?
Five: basic_ping.spec.ts, crud.spec.ts, newcontext_api.spec.ts, post_operation.spec.ts, put_operation.spec.ts. I fetched the GitHub tree. There is no delete.spec.ts in folder 01. Delete lives in folder 03.
Why does post_operation.spec.ts have a test titled PUT?
Classroom leftover. The second test posts /booking again. The real PUT is put_operation.spec.ts and update-booking.spec.ts. I do not rename the title in this series.
Does folder 02 call callApiWithRetry?
No. ApiHelper implements retry with default 3 attempts and 5000 ms polling. The two specs in 02_restfulbooker_apiHelper use post and put only.
Is there a PATCH spec?
BookingApi.patchBooking exists. Folders 01-05 do not contain a PATCH spec. I will not invent one.
What does newcontext_api.spec.ts hit?
https://gorest.in with X-Trace-Id demo-123, path //public/v2/users/1001?page=1&per_page=10, expect 200, then dispose(). It is not Restful Booker.
Does JSONPath always return an array?
Yes, by default. jsonpath-cheatsheet.md says so. Take [0] for a single match, or pass wrap false. The e2e spec takes [0].
Does the JSONPath e2e read store.json?
No. jsonpath-queries.e2e.spec.ts queries live Restful Booker responses. store.json is the cheat-sheet document. There is no store.spec.ts.
Why Ajv 8 and ajv-formats 3?
package.json pins ajv ^8.20.0 and ajv-formats ^3.0.1. Formats v3 requires Ajv 8. Without addFormats, format date is not enforced. schemaValidator.ts registers formats once.
Where does the create-booking schema live?
src/testdata/schemas/create-booking.schema.json. The spec loads it with fs.readFileSync relative to __dirname. Draft-07. additionalProperties false. Required bookingid plus booking.
What is 06_ai_datagen?
A sixth folder under src/tests/apiTests/ on feat-cucumber. It is on the tree. It is not part of Day 19. I did not fetch its files for this post and I will not describe them.
How do I run only API tests?
TTA_ENV=api and –project=api. The api project testMatches src/tests/apiTests spec files. Chromium testIgnores the same pattern.
What is Day 20 of this series?
CLI and CI. How you run this suite from the terminal, how CI=true changes retries and workers, how reports get served, how a pipeline installs browsers and uploads artifacts. Not more Restful Booker verbs.
<script type=”application/ld+json”> { “@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [ { “@type”: “Question”, “name”: “Does Playwright need a second tool for API testing?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. APIRequestContext and the built-in request fixture are enough to call Restful Booker. AdvancePlaywrightFramework1x on feat-cucumber adds ApiHelper, BookingApi, booker.fixture.ts, jsonpath-plus, and Ajv so calls are reusable and bodies are contracted.” } }, { “@type”: “Question”, “name”: “Why does Restful Booker /ping return 201 in Playwright?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “The public API returns 201 and src/tests/apiTests/01_restfulbooker_raw/basic_ping.spec.ts asserts toBe(201). Do not change it to 200 from habit.” } }, { “@type”: “Question”, “name”: “Why does Playwright deleteBooking expect 201?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “booking-crud.e2e.spec.ts documents that Restful Booker returns 201 Created on a successful DELETE. BookingApi.deleteBooking returns response.status(). The spec then GET /booking/{id} and expects 404 via getBookingResponse.” } }, { “@type”: “Question”, “name”: “Where does the Restful Booker token come from in the Playwright fixture layer?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “src/fixtures/booker.fixture.ts. The bookerToken fixture calls bookingApi.auth() (POST /auth with default admin / password123) and injects the token. booking-crud.e2e.spec.ts does not post /auth itself.” } }, { “@type”: “Question”, “name”: “What is the difference between BookingApi getBooking and getBookingResponse?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “getBooking throws if the status is not 2xx and parses JSON. getBookingResponse returns the raw APIResponse so a spec can assert 404 after delete. Both are in src/api/BookingApi.ts.” } }, { “@type”: “Question”, “name”: “Which files are in 01_restfulbooker_raw?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Five files on feat-cucumber: basic_ping.spec.ts, crud.spec.ts, newcontext_api.spec.ts, post_operation.spec.ts, and put_operation.spec.ts. There is no delete.spec.ts in folder 01. Delete is in 03_restfulbooker_fixture_e2e/booking-crud.e2e.spec.ts.” } }, { “@type”: “Question”, “name”: “Does Playwright JSONPath always return an array?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Yes. jsonpath-plus returns an array of matches by default. jsonpath-queries.e2e.spec.ts takes [0] for a single field. The cheat sheet documents wrap: false to unwrap. The e2e spec does not use wrap: false.” } }, { “@type”: “Question”, “name”: “Does the Playwright JSONPath e2e read store.json?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. jsonpath-queries.e2e.spec.ts queries live Restful Booker responses. store.json is the document for jsonpath-cheatsheet.md. There is no store.spec.ts.” } }, { “@type”: “Question”, “name”: “Where is the Playwright create-booking JSON Schema?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “src/testdata/schemas/create-booking.schema.json. create-booking-schema.spec.ts loads it with fs.readFileSync. Draft-07, additionalProperties false, validated by src/utils/schemaValidator.ts (Ajv 8 plus ajv-formats).” } }, { “@type”: “Question”, “name”: “How do I run only Playwright API tests in AdvancePlaywrightFramework1x?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “From the repo root on feat-cucumber: TTA_ENV=api and –project=api. The api project testMatches src/tests/apiTests spec files. The chromium project testIgnores that pattern.” } }, { “@type”: “Question”, “name”: “Is 06_ai_datagen part of Day 19?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “No. src/tests/apiTests/06_ai_datagen exists on feat-cucumber. Day 19 stops at 05_ajv_schema. This post does not quote 06 files.” } }, { “@type”: “Question”, “name”: “What is Day 20 of the JS to Playwright Framework series?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Day 20 is CLI and CI: how you run the suite from the terminal, how CI=true changes retries and workers in playwright.config.ts, how reports are served, and how a pipeline installs browsers and uploads artifacts.” } } ] } </script>
Tomorrow — Day 20: CLI and CI
A request helper is a layer. A fixture is a lifecycle. A schema is a contract. None of that ships itself.
Day 20 is the operator day. The same feat-cucumber tree already has the hooks: process.env.CI flips forbidOnly, retries 2, workers 4. Scripts exist: test, test:ui, test:debug, test:p0, test:report, test:report:ci (HTML on 0.0.0.0:9323), test:allure. Day 18 already fetched .github/workflows/playwright.yml and said it runs Playwright only, not cucumber-js. Tomorrow we read that workflow as an operator, not as a Cucumber footnote.
I will quote the config and the scripts that are actually on the branch. I will not invent a second workflow filename I have not fetched.
Tomorrow we run this suite the way a pipeline runs it. Traces, UI mode, report hosting, the CI env. Not more /booking verbs.
If you only remember one sentence from Day 19: request.post is a demo. ApiHelper is a layer. bookerToken is a fixture. Ajv is the contract.
Series hub (bookmark this): JavaScript to TypeScript to Playwright Advanced Framework — 21-Day Guide.
Master Playwright end to end
If you want these labs as a live classroom — raw Restful Booker, ApiHelper, BookingApi, JSONPath, Ajv contracts, then the CLI/CI day that puts the suite on a runner — join Playwright Automation Mastery at The Testing Academy. Lifetime access. Real projects. A job-ready API layer, not twenty specs that each paste /auth.
*This is Day 19 of 21. Draft only. Not published.*
