Target audience: Complete beginners — no Playwright, no TypeScript knowledge assumed. Format: Each lesson is thoroughly explained step by step, with every concept broken down so you understand not just what to type, but why it works. API under test: RESTful-Booker — a free, public hotel booking API.
| Lesson | Topic | Time |
|---|---|---|
| 1 | What is Playwright & API Testing? | 15 min |
| 2 | Environment Setup | 30 min |
| 3 | What Did That Command Create? | 20 min |
| 4 | Your First API Test | 45 min |
| 5 | API Test Runner Basics | 30 min |
| 6 | Three Data Strategies | 55 min |
| 7 | Validating Responses: Zod, Utilities & Assertions | 55 min |
| 8 | API Object Model | 60 min |
| 9 | Custom Fixtures & Environment Variables | 35 min |
| 10 | API Mocking | 45 min |
| 11 | Reporting, Config & CI/CD | 45 min |
| 12 | Full Framework & Running Tests | 30 min |
| Total | ~7.5 hours |
Time: 15 min
Playwright is an open-source automation framework developed by Microsoft. Most developers know it for browser automation — controlling Chrome, Firefox, or Safari programmatically to test web UIs. You can make it click buttons, fill forms, navigate pages, and verify that elements appear on screen.
But Playwright has a second, equally powerful capability built in: API testing. You can send raw HTTP requests (GET, POST, PUT, PATCH, DELETE) directly to a server without launching a browser at all. Playwright gives you a dedicated request object (called APIRequestContext) that acts as a full HTTP client, just like Postman or curl, but integrated directly into your test framework.
Official docs: https://playwright.dev/docs/api-testing
APIs are the backbone of modern applications. Every feature in a web or mobile app ultimately calls an API to fetch data, submit forms, or authenticate users. If the API breaks, the UI breaks too — no matter how polished the frontend is.
Here is why API testing matters:
- Speed — API tests run in milliseconds. They send a request and check the response. There is no browser to launch, no CSS to render, no JavaScript to execute. A suite of 100 API tests can finish in under 30 seconds.
- Stability — UI tests are notoriously flaky. A button might not be clickable because an animation is still running. A locator might fail because a developer changed a CSS class. API tests have none of these problems — you send a structured request and check a structured response.
- Early feedback — API tests catch backend contract breaks before any UI tests even run. If the API changes its response format, you know about it in the first minute of your CI pipeline.
- Coverage — You can test edge cases that are hard to reach through the UI: sending invalid JSON, omitting required fields, testing authentication failures, etc.
If you have used Playwright for UI testing, you are used to the page object — it represents a browser tab, and you call methods like page.click(), page.fill(), page.goto().
API testing replaces page with request:
| UI Testing | API Testing |
|---|---|
page object (browser tab) |
request object (HTTP client) |
Locators (page.getByText()) |
URL paths (/booking) |
| Clicks and keystrokes | HTTP methods (GET, POST, PUT...) |
| Assert on DOM elements | Assert on JSON response bodies |
| Waits for rendering | Waits for HTTP response |
| Browser needed | No browser needed for pure API tests |
Important: You still install a browser (Chromium) during setup because Playwright needs it internally for some utilities like HAR file recording/replay and context-level cookie handling. But the API tests themselves never open a browser window.
By the end of this course, you will have a professional-grade API test framework with:
- API Object Model — one client class per API domain (auth, booking). This is the API equivalent of Page Object Model (POM).
- Faker — randomized, realistic test data generation so every test run uses different data, preventing test-to-test interference.
- Zod — runtime schema validation for responses. Define the expected shape once, get both TypeScript types AND runtime checking.
- Three data strategies — static JSON (simple and debuggable), dynamic JSON templates (flexible placeholders), and Faker-generated (production-ready).
- Custom fixtures — automatic auth token injection so tests never need to think about authentication.
- API mocking — request interception and HAR file replay for offline testing.
- Allure reporting — rich, interactive test reports with timelines, graphs, and failure attachments.
- CI/CD (optional) — GitHub Actions and Jenkins configurations. Running locally is already a win.
The API we will test throughout this tutorial is RESTful-Booker, a free, publicly hosted hotel booking API. It supports:
- Health check —
GET /pingreturns 201 if the API is alive - Authentication —
POST /authreturns a token for write operations - Create Booking —
POST /bookingcreates a new booking (no auth needed) - Get Booking —
GET /booking/{id}retrieves a single booking (no auth) - Get Booking IDs —
GET /bookinglists all bookings, with optional name/date filters (no auth) - Update Booking —
PUT /booking/{id}replaces an entire booking (auth required) - Partial Update —
PATCH /booking/{id}updates specific fields only (auth required) - Delete Booking —
DELETE /booking/{id}removes a booking (auth required)
Official API documentation: RESTful-Booker provides a detailed API documentation page that lists every endpoint, its request/response schema, required headers, and example payloads. Keep this page open while following the tutorial — it is your reference for what each endpoint expects and returns.
RESTful-Booker quirk: The server auto-clears all bookings periodically, so our tests will always create fresh data before reading/updating/deleting it. This is actually good practice — tests should own their test data.
Time: 30 min Install: Node.js, npm, VS Code or Cursor
Node.js is a JavaScript runtime — it lets you run JavaScript code on your computer (outside of a browser). Before Node.js, JavaScript could only run inside a web browser. Now it runs servers, command-line tools, and yes, test frameworks.
Playwright is a Node.js library. When you install Playwright, you are downloading JavaScript code that runs on Node.js.
When you run npx playwright test, Node.js loads the Playwright library, which then uses your operating system's networking APIs to send HTTP requests.
npm stands for Node Package Manager. It has two jobs:
- Download and install libraries (called "packages" or "dependencies") for your project
- Manage which versions of those libraries your project uses
When you run npm install something, npm:
- Contacts the npm registry (a massive online database of JavaScript libraries)
- Downloads the library and its dependencies
- Places them in a
node_modules/folder in your project - Records the library name and version in
package.json
If Node.js is not installed on your machine:
- Go to https://nodejs.org
- Download the LTS (Long Term Support) Windows installer (.msi) — LTS versions are stable and recommended for most users
- Run the installer and leave all defaults checked
- Restart your terminal after installation so the new PATH environment variable takes effect (this lets your terminal find the
nodeandnpmcommands)
You need a code editor to write your test files:
- VS Code — https://code.visualstudio.com. Free, Microsoft-made editor with excellent TypeScript support.
- Cursor — https://www.cursor.com. Built on VS Code with AI-assisted coding features.
Either works fine for this tutorial. We will also install the Playwright Test for VS Code extension in Lesson 4.
Open a new terminal window (PowerShell, CMD, or Git Bash) and run:
node --version # Should show v20 or higher (e.g., v20.18.0)
npm --version # Should show 10 or higher (e.g., 10.9.0)If these commands produce errors, Node.js was not installed correctly or the terminal was not restarted.
Create a new folder for the project and initialize Playwright inside it:
mkdir playwright-api-testing
cd playwright-api-testing
npm init playwright@latestThe npm init playwright@latest command runs Playwright's scaffolding wizard. It asks a few questions:
- TypeScript: Yes — TypeScript gives us type checking, better IDE autocomplete, and catches errors before running tests
- Tests folder: Keep the default
tests/ - GitHub Actions workflow: No — we will add CI/CD configuration manually in Lesson 11
- Install browsers: Yes — even though we are doing API tests, Playwright needs a browser installed for its internal utilities (mocking, HAR recording, etc.)
After answering these questions, Playwright creates:
package.json— project manifest with Playwright as a dependencyplaywright.config.ts— central configurationtests/— a folder for test files (with an example spec)- A browser installation (Chromium) in a system cache
Once the scaffold is complete, install the extra libraries that the framework will use:
npm install -D @faker-js/faker zod dotenv allure-playwright allure-commandlineThe -D flag saves them as devDependencies — libraries needed for development and testing but not for production.
Here is what each library does:
| Library | Purpose | Why We Need It |
|---|---|---|
@faker-js/faker |
Generates random test data | Creates realistic names, dates, prices, etc. for every test run |
zod |
Runtime schema validation | Checks that API responses have the correct fields and types |
dotenv |
Loads .env files into process.env |
Manages credentials and config without hardcoding |
allure-playwright |
Playwright reporter for Allure | Generates rich, interactive test reports |
allure-commandline |
CLI tool to serve Allure reports | Opens the Allure dashboard in your browser |
After the scaffold and the npm install command, this is your project structure:
playwright-api-testing/
├── node_modules/ # Downloaded libraries (do not edit)
├── tests/ # Your test files go here
├── package.json # Lists all dependencies and scripts
├── playwright.config.ts # Central Playwright configuration
└── package-lock.json # Locks exact dependency versions
- VS Code:
code .(from the project folder) - Cursor:
cursor .(from the project folder)
You should see the folder structure in the editor's sidebar.
Time: 20 min
When you ran npm init playwright@latest, it generated several files and folders. Let us examine each one in detail.
This is the most important file in the project after your test files themselves. It tells Playwright:
- Where your tests are — the
testDiroption (default:./tests) - Which tests to run — the
testMatchpattern in each project - What settings to use — base URL, headers, timeouts, retries
- What reporters to use — how to display results (HTML, list, JUnit, Allure)
- How to run tests — in parallel or serial, with how many workers
A minimal config file looks like this:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'https://restful-booker.herokuapp.com',
},
});We will build on this config throughout the tutorial, adding reporters, projects, and environment variable loading.
This is the project manifest. It contains:
{
"name": "playwright-api-testing",
"version": "1.0.0",
"scripts": {},
"devDependencies": {
"@faker-js/faker": "^10.4.0",
"@playwright/test": "^1.60.0",
"allure-commandline": "^2.42.1",
"allure-playwright": "^3.9.0",
"dotenv": "^17.4.2",
"zod": "^4.4.3"
}
}Key sections:
scripts— you can define custom commands here (e.g.,"test": "npx playwright test"). We will leave it empty and usenpx playwright testdirectly.devDependencies— all the libraries we installed. The^caret means "allow minor and patch updates." For example,^10.4.0means version 10.4.0 or any 10.x.x higher.type: "commonjs"— tells Node.js to use CommonJS module system (require()instead ofimport). Playwright handles TypeScript compilation internally, so this does not affect our.tsfiles.
This folder contains all the downloaded libraries. It is generated by npm install and should never be edited manually. It is also the largest folder in the project (hundreds of megabytes). You should add it to .gitignore:
node_modules/
Anyone cloning your project runs npm install or npm ci to recreate this folder.
This is where Playwright looks for test files by default. Playwright recognizes files with these patterns:
*.spec.ts— e.g.,healthcheck.spec.ts,create-booking.api.spec.ts*.test.ts— e.g.,booking.test.ts
We will use .api.spec.ts as our naming convention (e.g., ping.api.spec.ts) and configure Playwright to only run files matching this pattern through our api-tests project.
Created after you run tests (if the HTML reporter is enabled). Contains an HTML file with test results, including pass/fail status, durations, errors, and any attached files (like failed response bodies). View it with:
npx playwright show-reportThis file tells Git which files and folders to ignore (not commit to version control). Add these entries:
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
allure-results/
.env
hars/
Why ignore each?
| Entry | Reason |
|---|---|
node_modules/ |
Recreated by npm install — hundreds of MB of libraries |
/test-results/ |
Generated every test run — contains traces, screenshots, etc. |
/playwright-report/ |
Generated every test run — HTML test report |
/blob-report/ |
Generated when using blob reporter |
/playwright/.cache/ |
Internal Playwright cache |
allure-results/ |
Generated Allure report data |
.env |
Contains local secrets (credentials) — never commit! |
hars/ |
Recorded API traffic files — personal to each developer |
This folder does not exist yet — you will create it. It will hold your source code: API clients, Zod schemas, factory functions, custom fixtures, and utility helpers.
The final structure will look like:
src/
├── api/
│ ├── auth/
│ │ ├── auth-client.ts
│ │ └── auth-schema.ts
│ ├── booking/
│ │ ├── booking-client.ts
│ │ ├── booking-schema.ts
│ │ └── booking-factory.ts
│ └── global/
│ └── global-setup.ts
├── fixtures/
│ └── api-fixture.ts
└── utils/
└── api-util.ts
Time: 45 min
In Playwright UI testing, every test receives a page object that represents a browser tab:
test('ui test', async ({ page }) => {
await page.goto('https://example.com');
});In API testing, you receive a request object — Playwright's APIRequestContext. It is a full HTTP client that can send GET, POST, PUT, PATCH, and DELETE requests.
test('api test', async ({ request }) => {
const response = await request.get('https://api.example.com/endpoint');
});The request fixture is built into Playwright. You do not need to create or configure it — Playwright gives it to you automatically when you list it as a test parameter.
Pro tip: Keep the RESTful-Booker API docs open in a browser tab. As you write each test, check the docs to see the exact request format, required headers, and expected response structure for each endpoint.
Create a new file tests/ping.api.spec.ts with the following content:
import { test, expect } from '@playwright/test';
test('API is reachable', async ({ request }) => {
const response = await request.get('https://restful-booker.herokuapp.com/ping');
expect(response.status()).toBe(201);
});Walk through this line by line:
-
import { test, expect } from '@playwright/test'— Imports Playwright'stestfunction (to define test cases) andexpect(to make assertions). These are the only two imports most tests need. -
test('API is reachable', async ({ request }) => { ... })— Defines a named test case. Theasynckeyword is required because API calls are asynchronous. The{ request }destructures therequestfixture from the test context — Playwright creates this fixture for you. -
const response = await request.get(...)— Sends an HTTP GET request to the specified URL. Theawaitkeyword pauses the test until the response arrives. Withoutawait, the test would continue executing before the server responds. -
expect(response.status()).toBe(201)— Asserts that the HTTP status code equals 201. RESTful-Booker's/pingendpoint returns HTTP 201 (Created) rather than the more common 200 (OK) to indicate the service is alive.
Writing the full URL in every test is repetitive and fragile. If the API domain changes, you would need to update every test file.
Instead, set a baseURL in playwright.config.ts. Then all requests can use relative paths:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://restful-booker.herokuapp.com',
extraHTTPHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
},
projects: [
{
name: 'api-tests',
testMatch: /.*\.api\.spec\.ts$/,
},
],
});With this config:
baseURL— Every relative URL is resolved against this base. Sorequest.get('/ping')becomesGET https://restful-booker.herokuapp.com/ping.extraHTTPHeaders— These headers are sent with every request automatically.Content-Type: application/jsontells the server we are sending JSON.Accept: application/jsontells the server we want JSON back. No need to repeat these in every request call.projects— Defines a project calledapi-teststhat only runs files matching*.api.spec.ts. This isolates API tests from any UI tests you might add later.
Now you can simplify the test:
test('API is reachable', async ({ request }) => {
const response = await request.get('/ping');
expect(response.status()).toBe(201);
});npx playwright test --project=api-testsBreaking this down:
npx— Executes a Node.js package without installing it globally. It looks for Playwright innode_modules/.bin/.playwright test— Invokes Playwright's test runner.--project=api-tests— Only runs tests matching theapi-testsproject configuration.
If everything is set up correctly, you should see:
Running 1 test using 1 worker
✓ 1 ping.api.spec.ts:3:1 › API is reachable (1.2s)
Every Playwright request returns an APIResponse object with these key properties and methods:
const response = await request.get('/booking');
response.status(); // HTTP status code (e.g., 200, 201, 403)
response.statusText(); // Status text (e.g., "OK", "Created", "Forbidden")
response.ok(); // true if status is between 200-299
response.headers(); // Object containing all response headers
response.json(); // Parses response body as JSON (returns a Promise)
response.text(); // Returns response body as plain text (returns a Promise)
response.url(); // The final URL after any redirectsMost API tests need to inspect the response body, not just the status code:
test('get all booking IDs', async ({ request }) => {
const response = await request.get('/booking');
expect(response.status()).toBe(200);
const body = await response.json();
console.log(body); // Array of { bookingid: number }
});The response.json() method reads the response body stream and parses it as JSON. It is asynchronous (returns a Promise), so you must await it.
Important: You cannot call response.json() more than once on the same response. The response body is a stream — once read, it is consumed. If you need both the raw body and the parsed body, read it as text first:
const text = await response.text();
const json = JSON.parse(text);Or use the getResponseDetails utility we will build in Lesson 7, which handles this safely.
Install the Playwright Test for VS Code extension (by Microsoft) from the VS Code marketplace. It adds:
- A testing sidebar where you can see all tests
- Run/debug buttons next to each test
- A "pick locator" tool (useful for UI tests)
- Failure output inline in the editor
After installing, click the beaker icon in the VS Code sidebar to see the test explorer.
Create tests/ping.api.spec.ts with this content:
import { test, expect } from '@playwright/test';
test('API is reachable', async ({ request }) => {
const response = await request.get('/ping');
expect(response.status()).toBe(201);
});Run it:
npx playwright test --project=api-testsYou should see a passing test. If it fails, check:
- Is the RESTful-Booker API reachable? Try
curl https://restful-booker.herokuapp.com/pingin a terminal. - Is
baseURLset correctly in the config? - Did you name the file with
.api.spec.ts?
Time: 30 min
Every test is defined by calling test() with a name and a function:
test('create a booking', async ({ request }) => {
const response = await request.post('/booking', {
data: {
firstname: 'Alice',
lastname: 'Smith',
totalprice: 500,
depositpaid: true,
bookingdates: {
checkin: '2026-01-01',
checkout: '2026-01-05',
},
additionalneeds: 'breakfast',
},
});
expect(response.status()).toBe(200);
});Key points about the data parameter:
- Playwright automatically serializes the JavaScript object to JSON (sets
Content-Type: application/json) - Playwright sets the
Content-Lengthheader automatically - You do not need to call
JSON.stringify()— Playwright handles it
Use test.describe() to organize related tests into a logical group:
test.describe('Booking CRUD', () => {
test('create a booking', async ({ request }) => {
// ...create a booking...
});
test('get a booking', async ({ request }) => {
// ...retrieve a booking...
});
test('delete a booking', async ({ request }) => {
// ...delete a booking...
});
});Benefits:
- Tests are visually grouped in the test runner output
- You can apply configuration to the entire group (e.g., tags, retries)
- The test output is indented under the describe block name
Every HTTP request in Playwright is asynchronous — it takes time for the request to travel to the server and the response to come back. JavaScript handles this with Promises and async/await.
Without await, the test does not wait for the response:
// WRONG — the test passes immediately without waiting for the response
test('wrong', ({ request }) => {
request.get('/booking'); // fires but never awaited — test passes instantly
});This test will always pass because it never actually checks the result.
With await, the test pauses until the response arrives:
// RIGHT — the test waits for the response
test('right', async ({ request }) => {
const response = await request.get('/booking');
expect(response.status()).toBe(200);
});Rules:
- Any function that uses
awaitmust be declaredasync - Any function that calls an
asyncfunction should eitherawaitit or use.then() - Playwright request methods (
get(),post(),put(),patch(),delete()) are all async response.json()andresponse.text()are also async
Playwright's expect() function provides matchers (assertion methods) that are specifically designed for testing:
expect(response.status()).toBe(200); // Exact equality (===)
expect(response.ok()).toBeTruthy(); // Any truthy value
expect(response.statusText()).toBe('OK'); // Exact string match
expect(response.headers()['content-type'])
.toContain('application/json'); // String inclusionFor API testing, these are the most common assertions:
| Matcher | What It Checks | Example |
|---|---|---|
.toBe(expected) |
Exact equality (===) |
expect(status).toBe(200) |
.toEqual(expected) |
Deep equality (recursive) | expect(body).toEqual({ id: 1, name: "Alice" }) |
.toMatchObject(expected) |
Partial match (ignores extra fields) | expect(body).toMatchObject({ name: "Alice" }) |
.toBeTruthy() |
Is truthy (not null/undefined/0/false) | expect(response.ok()).toBeTruthy() |
.toBeOK() |
Status is 200-299 (Playwright custom) | expect(response).toBeOK() |
.toContain(item) |
Array or string includes value | expect(text).toContain('json') |
.toHaveProperty(key) |
Object has property | expect(body).toHaveProperty('bookingid') |
expect.any(type) |
Is any value of given type | expect.any(Number) |
expect.objectContaining(partial) |
Object contains subset | expect.objectContaining({ name: 'Alice' }) |
API tests often need to chain multiple requests. The most common pattern is: create data, capture the ID, then use that ID in subsequent requests.
test('get created booking', async ({ request }) => {
// Step 1: Create a booking
const postRes = await request.post('/booking', {
data: {
firstname: 'Bob',
lastname: 'Brown',
totalprice: 300,
depositpaid: false,
bookingdates: {
checkin: '2026-02-01',
checkout: '2026-02-03',
},
},
});
const bookingId = (await postRes.json()).bookingid;
// Step 2: Get that booking using the ID from Step 1
const getRes = await request.get(`/booking/${bookingId}`);
expect(getRes.status()).toBe(200);
});This pattern — POST to create, then use the returned ID — is the foundation of almost all API testing. You will see it used in every test file in this framework.
Create a file with 2 passing tests and 1 intentional failure to see how Playwright reports failures:
// tests/demo.api.spec.ts
import { test, expect } from '@playwright/test';
test('passing test 1', async ({ request }) => {
const response = await request.get('/ping');
expect(response.status()).toBe(201);
});
test('passing test 2', async ({ request }) => {
const response = await request.get('/booking');
expect(response.status()).toBe(200);
});
test('intentional failure', async ({ request }) => {
const response = await request.get('/booking/99999999');
expect(response.status()).toBe(200); // This will fail — the booking does not exist
});Run it:
npx playwright test --project=api-testsYou will see:
- 3 passing tests: the 2 from
demo.api.spec.tsplus the 1 fromping.api.spec.ts(which you created in Lesson 4) - 1 failure: the intentional failure in
demo.api.spec.ts - The actual status code (should be 418 — RESTful-Booker returns I'm a teapot for unknown bookings) vs the expected 200
The ping.api.spec.ts test is included because our api-tests project matches all *.api.spec.ts files. From now on, every time you run tests with --project=api-tests, Playwright will execute every test across all .api.spec.ts files in the tests/ folder.
Time: 55 min
Install: @faker-js/faker (already installed in Lesson 2)
Test data is the input you send to an API. Choosing how to provide it is one of the first decisions you make when building a framework. This project demonstrates three distinct strategies, each with its own trade-offs.
Best for: Known, repeatable scenarios. Debugging. Testing specific edge cases.
Static JSON files are exactly what they sound like — hardcoded JSON objects saved in .json files. The test imports the file and uses the data directly.
Create test-data/static-booking-data.json:
{
"validBooking": {
"firstname": "John",
"lastname": "Doe",
"totalprice": 1000,
"depositpaid": true,
"bookingdates": {
"checkin": "2026-12-31",
"checkout": "2027-01-01"
},
"additionalneeds": "Playwright API Test"
}
}Create the test — tests/create-booking-static.spec.ts:
import { test, expect, APIResponse } from '@playwright/test';
import requestData from '../test-data/static-booking-data.json';
test('[POST] Create Booking using Static Data', async ({ request }, testInfo) => {
// Attach the request body to the test report for debugging
await testInfo.attach('REQUEST', {
body: JSON.stringify(requestData.validBooking, null, 2),
contentType: 'application/json',
});
const startTime = Date.now();
const response = await request.post('/booking', {
data: requestData.validBooking,
timeout: 10_000,
});
const duration = Date.now() - startTime;
// Inline helper to structure the response
async function getResponseDetails(method: string, response: APIResponse, duration: number) {
return {
url: response.url(),
method,
duration: `${duration}ms`,
status: response.status(),
statusText: response.statusText(),
headers: response.headers(),
body: await response.json(),
};
}
const responseDetails = await getResponseDetails('POST', response, duration);
// Attach the response to the test report
await testInfo.attach('RESPONSE', {
body: JSON.stringify(responseDetails, null, 2),
contentType: 'application/json',
});
test.step('Validation', async () => {
// Status code assertions
expect(response.status(), 'Status should be 200').toBe(200);
expect(response, 'Should be Success Status Code').toBeOK();
// Header assertion
expect(response.headers()['content-type']).toContain('application/json');
// Single property assertions
expect(responseDetails.body.booking.firstname).toBe(requestData.validBooking.firstname);
expect(responseDetails.body.booking.totalprice).toBe(requestData.validBooking.totalprice);
expect(responseDetails.body.booking.depositpaid).toBe(requestData.validBooking.depositpaid);
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkin');
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkout');
// Bulk property validation using toMatchObject
expect(responseDetails.body).toMatchObject({
bookingid: expect.any(Number),
booking: {
firstname: requestData.validBooking.firstname,
lastname: requestData.validBooking.lastname,
totalprice: requestData.validBooking.totalprice,
depositpaid: requestData.validBooking.depositpaid,
bookingdates: expect.objectContaining({
checkin: requestData.validBooking.bookingdates.checkin,
}),
additionalneeds: requestData.validBooking.additionalneeds,
},
});
});
});Why this approach matters: Notice we use testInfo.attach() to save both the REQUEST and RESPONSE to the HTML report. This means when you inspect a test failure, you can see exactly what was sent and what came back. The test.step('Validation', ...) block organizes assertions under a named step in the report.
Pros: Simple, debuggable, no extra code needed. Cons: Same data every run — tests can interfere if they create duplicate records. Does not test "realistic" data variation.
Best for: Flexible scenarios where you want to control specific values while keeping the structure in a JSON file.
Instead of hardcoding every value, you create a template with {0}, {1}, {2}, etc. placeholders and replace them at runtime.
Create test-data/dynamic-booking-data.json:
{
"firstname": "{0}",
"lastname": "{1}",
"totalprice": "{2}",
"depositpaid": "{3}",
"bookingdates": {
"checkin": "{4}",
"checkout": "{5}"
},
"additionalneeds": "{6}"
}The formatApiRequest utility — src/utils/api-util.ts:
export async function formatApiRequest(template: string, values: any[]): Promise<string> {
return template.replace(/"?\{(\d+)\}"?/g, (_match, p1) => {
const index = parseInt(p1, 10);
if (index >= values.length) return _match;
const value = values[index];
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return `"${value}"`;
});
}How the regex works: The pattern "?\{(\d+)\}"? matches:
- Optional double quotes
"? - An opening brace
\{ - One or more digits
(\d+)— this is captured as a group - A closing brace
\} - Optional double quotes
"
So it matches {0}, "{1}", etc. The captured digit is used to look up the corresponding value from the values array.
Numbers and booleans are returned without quotes (valid JSON requires true, not "true"). Strings are returned with quotes.
Create the test — tests/create-booking-dynamic.spec.ts:
import { test, expect, APIResponse } from '@playwright/test';
import { formatApiRequest } from '../src/utils/api-util';
import path from 'path';
import fs from 'fs';
test('[POST] Create Booking using Dynamic Data', async ({ request }, testInfo) => {
const filePath = path.join(__dirname, '../test-data/dynamic-booking-data.json');
const jsonTemplate = fs.readFileSync(filePath, 'utf-8');
const values = ['Mark', 'Miller', 1500, true, '2026-12-01', '2026-12-05', 'Massage'];
const requestString = await formatApiRequest(jsonTemplate, values);
const requestData = JSON.parse(requestString);
await testInfo.attach('REQUEST', {
body: requestString,
contentType: 'application/json',
});
const startTime = Date.now();
const response = await request.post('/booking', {
data: requestData,
timeout: 10_000,
});
const duration = Date.now() - startTime;
async function getResponseDetails(method: string, response: APIResponse, duration: number) {
return {
url: response.url(),
method,
duration: `${duration}ms`,
status: response.status(),
statusText: response.statusText(),
headers: response.headers(),
body: await response.json(),
};
}
const responseDetails = await getResponseDetails('POST', response, duration);
await testInfo.attach('RESPONSE', {
body: JSON.stringify(responseDetails, null, 2),
contentType: 'application/json',
});
test.step('Validation', async () => {
// Status code assertions
expect(response.status(), 'Status should be 200').toBe(200);
expect(response, 'Should be Success Status Code').toBeOK();
// Header assertion
expect(response.headers()['content-type']).toContain('application/json');
// Single property assertions
expect(responseDetails.body.booking.firstname).toBe(requestData.firstname);
expect(responseDetails.body.booking.totalprice).toBe(requestData.totalprice);
expect(responseDetails.body.booking.depositpaid).toBe(requestData.depositpaid);
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkin');
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkout');
// Bulk validation
expect(responseDetails.body).toMatchObject({
bookingid: expect.any(Number),
booking: {
firstname: requestData.firstname,
lastname: requestData.lastname,
totalprice: requestData.totalprice,
depositpaid: requestData.depositpaid,
bookingdates: expect.objectContaining({
checkin: requestData.bookingdates.checkin,
}),
additionalneeds: requestData.additionalneeds,
},
});
});
});Pros: Flexible — you can pass different values without editing the JSON file. The template stays as a documentation of the API's expected structure. Cons: Manual value arrays are still static per test. You still need to manage the placeholder-to-value mapping.
Best for: Production test suites. Randomized, realistic data that prevents test interference.
Faker (@faker-js/faker) generates realistic random data: names, addresses, dates, numbers, etc. Every test run uses different data, which:
- Prevents collisions (two test runs creating the same booking)
- Uncovers edge cases (a developer might hardcode "John" and miss that the API fails on "José")
- Feels more like real-world usage
Create the test — tests/create-booking-faker.spec.ts:
import { test, expect, APIResponse } from '@playwright/test';
import { formatApiRequest } from '../src/utils/api-util';
import path from 'path';
import fs from 'fs';
import { faker } from '@faker-js/faker';
test('[POST] Create Booking using Faker Data', async ({ request }, testInfo) => {
const filePath = path.join(__dirname, '../test-data/dynamic-booking-data.json');
const jsonTemplate = fs.readFileSync(filePath, 'utf-8');
// Helper to pick a random hotel note
const generateHotelNote = () => {
const requests = [
'late check-in', 'extra towels', 'high floor', 'quiet room',
'near elevator', 'king size bed', 'honeymoon package', 'vegan breakfast options',
];
return faker.helpers.arrayElement(requests);
};
// Generate realistic dates
const checkIn = faker.date.soon({ days: 30 }); // Any date in the next 30 days
const checkOut = faker.date.soon({ days: 7, refDate: checkIn }); // 1-7 days after check-in
const formatDate = (date: Date) => date.toISOString().split('T')[0];
const values = [
faker.person.firstName(),
faker.person.lastName(),
faker.number.int({ min: 500, max: 2000 }), // Random price between 500-2000
faker.datatype.boolean(), // Random true/false
formatDate(checkIn),
formatDate(checkOut),
generateHotelNote(),
];
const requestString = await formatApiRequest(jsonTemplate, values);
const requestData = JSON.parse(requestString);
await testInfo.attach('REQUEST', {
body: requestString,
contentType: 'application/json',
});
const startTime = Date.now();
const response = await request.post('/booking', {
data: requestData,
timeout: 10_000,
});
const duration = Date.now() - startTime;
async function getResponseDetails(method: string, response: APIResponse, duration: number) {
return {
url: response.url(),
method,
duration: `${duration}ms`,
status: response.status(),
statusText: response.statusText(),
headers: response.headers(),
body: await response.json(),
};
}
const responseDetails = await getResponseDetails('POST', response, duration);
await testInfo.attach('RESPONSE', {
body: JSON.stringify(responseDetails, null, 2),
contentType: 'application/json',
});
test.step('Validation', async () => {
expect(response.status(), 'Status should be 200').toBe(200);
expect(response, 'Should be Success Status Code').toBeOK();
expect(response.headers()['content-type']).toContain('application/json');
expect(responseDetails.body.booking.firstname).toBe(requestData.firstname);
expect(responseDetails.body.booking.totalprice).toBe(requestData.totalprice);
expect(responseDetails.body.booking.depositpaid).toBe(requestData.depositpaid);
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkin');
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkout');
expect(responseDetails.body).toMatchObject({
bookingid: expect.any(Number),
booking: {
firstname: requestData.firstname,
lastname: requestData.lastname,
totalprice: requestData.totalprice,
depositpaid: requestData.depositpaid,
bookingdates: expect.objectContaining({
checkin: requestData.bookingdates.checkin,
}),
additionalneeds: requestData.additionalneeds,
},
});
});
});Pros: Realistic data, no test collisions, catches edge cases. Cons: Slightly harder to debug (you need to check the report attachments to see what data was used). The factory pattern (Lesson 8) solves this by combining Faker with type-safe payload generation.
- Start with static JSON — Easy to debug, understand, and get working
- Try dynamic templates — Learn the placeholder pattern, understand how data flows from template to request
- Settle on Faker with factory functions — The production-ready approach we use in the finished framework (Lesson 8)
Time: 55 min
This lesson ties together three things that work as one: defining response shapes (Zod), logging responses consistently (Utilities), and asserting on them (Assertions).
When you test an API, there are two critical moments where data structure matters:
- Sending a request — you need to build a payload with the correct fields and types. If you send
{ totalprice: "five hundred" }instead of{ totalprice: 500 }, the API might silently coerce it, return an error, or worse — store wrong data. - Receiving a response — you need to verify the API returned the shape you expect. Did it include
bookingid? Istotalpricea number, not a string? Are the date fields present?
Zod solves both problems with a single definition. You declare one schema per data structure, and you get:
- Compile-time TypeScript types via
z.infer— your IDE, the compiler, and your factory functions all know the exact shape - Runtime validation via
.safeParse()— your tests actually check that real API responses match the expected shape
Without a schema library, tests manually check each field:
// Manual validation — verbose, error-prone, and easy to forget a field
expect(typeof body.bookingid).toBe('number');
expect(typeof body.booking.firstname).toBe('string');
expect(typeof body.booking.totalprice).toBe('number');
expect(typeof body.booking.depositpaid).toBe('boolean');Problems with this approach:
- You must remember to check every field
- Each check tests only the type, not the structure
- If the API changes, you must update every test file
- There is no single source of truth for "what does a booking look like?"
In this framework, every Zod schema serves two roles simultaneously:
| Role | When It Applies | What It Protects Against |
|---|---|---|
| Request Schema | At compile time — when building payloads | Sending wrong field names, wrong types, or missing required fields |
| Response Schema | At runtime — when validating API responses | API contract breaking — missing fields, wrong types, unexpected nulls |
Both roles use the same schema definition. You never write the shape twice.
Let us examine the full booking schema file and understand why each piece exists:
import { z } from 'zod';
/***** Booking Dates *****/
export const BookingDatesSchema = z.object({
checkin: z.string(),
checkout: z.string(),
});
export type BookingDates = z.infer<typeof BookingDatesSchema>;Why a separate schema for dates? Because bookingdates appears nested inside other schemas. By extracting it into its own BookingDatesSchema, we can reuse it wherever dates appear. If the API changes to return checkin/checkout as ISO timestamps instead of date strings, we update one schema — every parent schema inherits the change automatically.
z.object({...}) defines an object schema. Each key maps to a type constraint:
z.string()— must be a stringz.number()— must be a numberz.boolean()— must be a booleanz.string().optional()— can be a string or undefined
z.infer<typeof BookingDatesSchema> extracts a TypeScript type from the schema. This line:
export type BookingDates = z.infer<typeof BookingDatesSchema>;Is equivalent to manually writing:
type BookingDates = {
checkin: string;
checkout: string;
};But you never write that manually — Zod generates it from the schema. This means the TypeScript type and the runtime validator are always in sync. If you edit the schema, both the type and the validator update together.
/***** Create Booking (POST) API *****/
export const createBookingRequestSchema = z.object({
firstname: z.string(),
lastname: z.string(),
totalprice: z.number(),
depositpaid: z.boolean(),
bookingdates: BookingDatesSchema, // Reuses the schema above
additionalneeds: z.string().optional(), // Marked optional since the API sometimes omits it
});
export type createBookingRequest = z.infer<typeof createBookingRequestSchema>;This schema defines what you must send to create a booking. At compile time, TypeScript enforces that any payload you build matches createBookingRequest. If your factory function (Lesson 8) accidentally returns { firstname: 123 } (number instead of string), TypeScript catches it before the test runs.
.optional() marks a field as not required. We use it for additionalneeds because the Booking API sometimes omits this field in responses. Without .optional(), Zod would fail validation whenever the field was missing.
export const createBookingResponseSchema = z.object({
bookingid: z.number(),
booking: createBookingRequestSchema, // Reuses the schema above!
});
export type createBookingResponse = z.infer<typeof createBookingResponseSchema>;This schema defines what the API returns after creating a booking. The response has a bookingid (auto-generated by the server) and a booking object that has the same shape as the request. By reusing createBookingRequestSchema here, we say "the response's booking object has the same structure as what we sent."
If the API adds a new field to bookings, we only update createBookingRequestSchema — the response schema updates automatically.
/***** Get Booking (GET) API *****/
export const getBookingResponseSchema = createBookingRequestSchema;
// GET /booking/{id} returns the same booking shape, but without bookingid wrapper
export type getBookingResponse = z.infer<typeof getBookingResponseSchema>;
/***** Get Booking Ids (GET) API *****/
export const getBookingIdsResponseSchema = z.array(
z.object({
bookingid: z.number(),
})
);
export type getBookingIdsResponse = z.infer<typeof getBookingIdsResponseSchema>;
/***** Update Booking (PUT) API *****/
export const updateBookingRequestSchema = createBookingRequestSchema;
export const updateBookingResponseSchema = createBookingRequestSchema;
// PUT replaces the entire booking, so it uses the same full schema
/***** Partial Update Booking (PATCH) API *****/
export const partialUpdateBookingRequestSchema = createBookingRequestSchema.partial();
export const partialUpdateBookingResponseSchema = createBookingRequestSchema;Understanding schema reuse across endpoints:
| Endpoint | Request Schema | Response Schema | Why |
|---|---|---|---|
POST /booking |
createBookingRequestSchema |
createBookingResponseSchema (with bookingid) |
Creating returns the server-assigned ID |
GET /booking/{id} |
N/A (no body) | getBookingResponseSchema = createBookingRequestSchema |
Getting returns the same booking shape |
PUT /booking/{id} |
updateBookingRequestSchema = createBookingRequestSchema |
updateBookingResponseSchema = createBookingRequestSchema |
PUT replaces the entire booking |
PATCH /booking/{id} |
.partial() — all fields optional |
Same as full booking | PATCH only sends changed fields |
DELETE /booking/{id} |
N/A (no body) | N/A (returns plain text "Created") | No structured response |
The .partial() method is a Zod utility that makes every field optional. It is ideal for PATCH semantics:
// Without .partial(): all fields required
createBookingRequestSchema.parse({ firstname: 'Alice' }); // Error: lastname required
// With .partial(): all fields optional
createBookingRequestSchema.partial().parse({ firstname: 'Alice' }); // OKThis means the TypeScript type partialUpdateBookingRequest automatically reflects that every field might be present or absent — exactly matching PATCH behavior.
Once you define a response schema, you validate real API responses against it:
const result = createBookingResponseSchema.safeParse(responseBody);
if (result.success) {
console.log('Valid!', result.data);
// result.data is typed as createBookingResponse — full type safety
} else {
console.error('Invalid!', result.error);
// result.error contains field-level error messages
}safeParse (as opposed to parse) never throws. It always returns a discriminated union:
{ success: true, data: T }— the parsed and validated data, correctly typed{ success: false, error: ZodError }— validation errors with exact field paths and expected types
This makes it perfect for assertions:
const result = createBookingResponseSchema.safeParse(responseDetails.body);
expect(result.success,
`Schema Validation:\n${!result.success ? z.prettifyError(result.error) : ''}`
).toBeTruthy();If the API response is missing a field (e.g., the API stops returning totalprice), the error message will say something like:
"booking.totalprice": Required
If a field has the wrong type (e.g., totalprice comes back as a string "500" instead of a number 500):
"booking.totalprice": Expected number, received string
This gives you actionable failure messages — you know exactly what the API contract violation is, not just "expected 200 but got 500."
Zod Schema (single source of truth)
/ \
/ \
z.infer (compile-time) safeParse (runtime)
| |
v v
Factory generates typed Client validates response
payload (TypeScript catches (test fails if shape is
errors before test runs) wrong at runtime)
| |
v v
Client sends payload Test asserts on
with full type safety validated response
Every piece — factory, client, test — references the same Zod schema. This is the key insight: the schema is the contract. Define it once, and TypeScript enforces it at compile time while Zod enforces it at runtime.
Raw response.json() works, but professional frameworks need structured logging, timing, and failure reporting.
import { APIResponse, test } from '@playwright/test';
export function stringifyJson(object: Record<string, any>): string {
return JSON.stringify(object, null, 2);
}
export async function getResponseDetails<T>(method: string, response: APIResponse, duration: number) {
const rawText = await response.text();
const contentType = response.headers()['content-type'] || '';
let parsedBody: any = null;
// 1. Safely parse JSON based on Content-Type
if (contentType.includes('application/json') && rawText) {
try {
parsedBody = JSON.parse(rawText) as T;
} catch (error) {
parsedBody = `[Failed to parse JSON: ${error}] Original Text: ${rawText}`;
}
} else {
parsedBody = rawText || null;
}
// 2. Build structured response log
const responseLog = {
url: response.url(),
method: method,
duration: `${duration}ms`,
status: response.status(),
statusText: response.statusText(),
response: {
headers: response.headers(),
body: parsedBody,
},
};
const formattedLog = stringifyJson(responseLog);
// 3. Smart reporting: attach failures to report, log successes to console
if (!response.ok()) {
await test.info().attach(
`[FAILED] ${method} | ${response.status()} - ${response.statusText()} | (${duration}ms)`,
{ contentType: 'application/json', body: formattedLog }
);
} else {
console.log(
`\n[SUCCESS] ${method} | ${response.status()} - ${response.statusText()} | (${duration}ms)\n`,
formattedLog
);
}
// 4. Return structured response object
return {
url: response.url(),
method: method,
duration: `${duration}ms`,
status: response.status(),
statusText: response.statusText(),
isResponseSuccessful: response.ok(),
headers: response.headers(),
body: parsedBody,
};
}What this function does, step by step:
- Reads the raw response text —
response.text()reads the body once. From here we can parse JSON safely, avoiding the "body already consumed" error. - Checks Content-Type — Only attempts JSON parsing if the server says it is JSON. If the Content-Type is something else (like
text/plain), it returns the raw text. - Safe JSON parsing — Wrapped in try/catch. If the API returns malformed JSON, the test gets a descriptive error message instead of crashing.
- Builds a structured log — Contains the URL, method, duration, status, headers, and body.
- Smart reporting — Failed responses are attached to the Playwright HTML report as searchable JSON attachments. Successful responses are logged to stdout.
- Returns structured data — The calling code gets a consistent object with
status,body,headers,isResponseSuccessful, etc.
export async function formatApiRequest(template: string, values: any[]): Promise<string> {
return template.replace(/"?\{(\d+)\}"?/g, (match, p1) => {
const index = parseInt(p1, 10);
if (index >= values.length) return match;
const value = values[index];
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return `"${value}"`;
});
}This utility powers the dynamic JSON strategy (Lesson 6, Strategy 2). It replaces {0}, {1}, etc. placeholders in a JSON template with actual values, handling type-aware quoting (numbers/booleans without quotes, strings with quotes).
Once Zod schemas and getResponseDetails are in place, assertions become clean and consistent.
expect(responseDetails.status).toBe(200);
expect(responseDetails.isResponseSuccessful).toBe(true);
expect(responseDetails.statusText).toBe('OK');The isResponseSuccessful property comes from getResponseDetails and is equivalent to response.ok() (true for any 2xx status).
// Individual field checks
expect(responseDetails.body.booking.firstname).toBe(payload.firstname);
expect(responseDetails.body.booking.totalprice).toBe(payload.totalprice);
expect(responseDetails.body.booking.bookingdates).toHaveProperty('checkin');toMatchObject checks that specific fields match, ignoring any extra fields in the actual response. This is ideal for API testing because real APIs often return more fields than you expect.
expect(responseDetails.body).toMatchObject({
bookingid: expect.any(Number),
booking: {
firstname: payload.firstname,
lastname: payload.lastname,
totalprice: payload.totalprice,
bookingdates: expect.objectContaining({
checkin: payload.bookingdates.checkin,
}),
},
});expect.any(Number)— matches any number (bookings have auto-generated IDs)expect.objectContaining({...})— matches if the object contains at least these fields
const result = createBookingResponseSchema.safeParse(responseDetails.body);
expect(result.success,
`Schema Validation:\n${!result.success ? z.prettifyError(result.error) : ''}`
).toBeTruthy();This is the most powerful assertion pattern. It validates the entire response structure against the Zod schema with a single line. If validation fails, the error message shows exactly which fields are wrong.
expect(responseDetails.headers['content-type']).toContain('application/json');Negative tests verify that invalid inputs produce correct error responses:
test('PUT with no auth returns 403', async ({ bookingClient }) => {
const putRes = await bookingClient.updateBookingApi<any>(bookingId, putPayload, '');
expect(putRes.isResponseSuccessful).toBe(false);
expect(putRes.status).toBe(403);
expect(putRes.statusText).toBe('Forbidden');
});Note the empty string '' passed as the overrideToken — this explicitly removes authentication to test the 403 Forbidden response.
For endpoints that return arrays, use array-specific matchers:
// Assert the response is an array with at least one item
expect(responseDetails.body.length).toBeGreaterThan(0);
// Assert a specific item exists in the array
const bookingIds = result.data;
expect(bookingIds).toContainEqual({ bookingid: expectedBookingId });toContainEqual checks if the array contains an object that deeply equals the expected object.
| Assertion | Best Used For | Comparison Type |
|---|---|---|
.toBe() |
Status codes, booleans, exact strings | Identity/Strict (===) |
.toEqual() |
Full JSON objects or arrays | Deep Equality |
.toMatchObject() |
Checking specific fields in a JSON response | Partial Match |
.toBeTruthy() / .toBe(true) |
Success checks | Truthy |
.toBeOK() |
Quick 200-299 status check | Range check |
.toHaveProperty() |
Checking if a property exists | Property existence |
.toContain() |
String or array inclusion | Inclusion |
.toContainEqual() |
Deep equality check in arrays | Deep equality |
expect.objectContaining() |
Partial matches inside nested objects | Asymmetric Matcher |
expect.any() |
Ignoring dynamic values (IDs, dates) | Type check |
Time: 60 min
API Object Model (AOM) is the API-testing equivalent of Page Object Model (POM). In UI testing, POM creates one class per page (e.g., LoginPage, HomePage) to encapsulate how to interact with that page. In API testing, AOM creates one class per API domain (e.g., Auth, Booking) to encapsulate how to interact with that API.
But AOM goes further than POM — each domain gets three files, not just one. This is because APIs have three distinct concerns that should not be mixed:
| Concern | File | Why It Exists |
|---|---|---|
| What is the data shape? | *-schema.ts |
Defines the contract — field names, types, required vs optional |
| How do we send it? | *-client.ts |
Defines the HTTP communication — URLs, methods, headers, error handling |
| How do we create test data? | *-factory.ts |
Defines data generation — Faker calls, randomization, override support |
A common beginner mistake is to put everything in one file: schemas, HTTP calls, and data generation all mixed together. This creates problems:
- Duplicate schemas — you define the booking shape in the client, in the test, and in the factory. When the API changes, you have to find and update every copy.
- Tight coupling — changing how data is generated (e.g., switching from Faker v7 to v8) means editing the HTTP client, which risks breaking request logic.
- Hard to reuse — you cannot use the same schema for validation in a different client if it is embedded inside a class method.
- No single source of truth — there is no one place to look to answer "what does a booking look like?"
The three-file pattern solves all of these:
src/api/booking/
├── booking-schema.ts ← The CONTRACT: defines the data shape
├── booking-client.ts ← The COMMUNICATION: sends HTTP requests
└── booking-factory.ts ← The DATA: generates test payloads
Each file has one responsibility and depends only on the schema file:
booking-factory.ts ──imports──> booking-schema.ts <──imports── booking-client.ts
│
└──imports──> zod (external library)
The schema sits in the middle. Neither the client nor the factory knows about each other — they both only know the schema.
The schema file is the foundation of every domain. Before you write any HTTP code or data generation, you must answer:
- What fields does this API endpoint expect? (request schema)
- What fields does this API endpoint return? (response schema)
- Which fields are required vs optional?
- What are the exact types? (string, number, boolean, nested object, array)
By defining the schema first, you:
- Document the API contract — anyone reading the code can see exactly what shape the API expects
- Get TypeScript types for free — every other file gets type-checked against the schema
- Catch errors early — if you mistype a field name in the factory or client, TypeScript catches it at compile time, not at runtime
- Enable IDE autocomplete — when writing
payload., your IDE suggestsfirstname,lastname,totalprice, etc.
Here is the journey of a single test, showing how all three files participate:
1. TEST Calls factory to generate payload
│
2. BOOKING-FACTORY Uses Zod types from booking-schema
(src/api/booking/ Calls faker methods for random data
booking-factory.ts) Returns typed payload
│
3. TEST Passes typed payload to client method
│
4. BOOKING-CLIENT Receives typed payload
(src/api/booking/ Sends HTTP request via this.request
booking-client.ts) Delegates to getResponseDetails
Returns structured response
│
5. TEST Asserts on status codes
Asserts on body fields
Validates with Zod schema
At no point does the test manually construct HTTP request options or manually parse response bodies. The client handles all of that. The test stays at a high level: "generate data, send request, check result."
A common question is "what belongs in the client vs what belongs in the test?" Here is the rule:
| Goes in the Client | Goes in the Test |
|---|---|
| HTTP method (GET, POST, PUT, etc.) | Which scenario to test (positive, negative, edge case) |
| URL path construction | Specific override values for edge cases |
| Header construction (auth token injection) | Assertions on the response |
| Error handling (throw on non-2xx) | Zod schema validation |
| Timing measurement | Test data generation parameters |
Logging via getResponseDetails |
Tagging (positive/negative) |
| Consistency (every request follows the same pattern) | Flexibility (each test can be unique) |
The Auth domain is simpler than Booking — it has no factory (we do not generate random credentials; credentials are fixed from .env), so it only has two files: schema and client.
import { z } from 'zod';
/***** Create Token (POST) API *****/
export const createTokenRequestSchema = z.object({
username: z.string(),
password: z.string(),
});
export type createTokenRequest = z.infer<typeof createTokenRequestSchema>;
export const createTokenResponseSchema = z.object({
token: z.string(),
});
export type createTokenResponse = z.infer<typeof createTokenResponseSchema>;The auth API is simple: send username and password as strings, get a token string back. Even for this simple case, the schema matters:
createTokenRequesttype — ensures the fixture and any test that creates a token passes{ username: string, password: string }, not reversed or misspelled fieldscreateTokenResponsetype — lets the fixture extractresponseDetails.body.tokenwith full type safety (the fixture knowsbodyhas atokenfield that is a string)
import { z } from 'zod';
/***** Create Token (POST) API *****/
export const createTokenRequestSchema = z.object({
username: z.string(),
password: z.string(),
});
export type createTokenRequest = z.infer<typeof createTokenRequestSchema>;
export const createTokenResponseSchema = z.object({
token: z.string(),
});
export type createTokenResponse = z.infer<typeof createTokenResponseSchema>;The auth API is simple: send username and password, get a token string back.
The client is the only file in the domain that actually talks to the network. Its job is to take typed data, send it to the correct URL, handle errors, and return structured results.
import { APIRequestContext } from '@playwright/test';
import { createTokenRequest } from './auth-schema';
import { getResponseDetails } from '../../utils/api-util';
export class AuthClient {
constructor(private request: APIRequestContext) {}
async createTokenApi<T>(payload: createTokenRequest) {
const startTime = Date.now();
const response = await this.request.post('/auth', {
data: payload,
timeout: 2_000,
});
const duration = Date.now() - startTime;
if (!response.ok()) {
throw new Error(`Create Token [POST] API failed.\nStatus: ${response.status()} ${response.statusText()}`);
}
const responseData = await response.json();
// RESTful-Booker quirk: bad credentials return 200 with { reason: "Bad credentials" }
if (responseData.reason) {
throw new Error(`Authentication Failed! Server returned reason: "${responseData.reason}".`);
}
return getResponseDetails<T>('POST', response, duration);
}
}Every design decision has a purpose:
-
constructor(private request: APIRequestContext)— The constructor takes Playwright'srequestfixture as a dependency. This is dependency injection: the client does not create its own HTTP client; it receives one. This lets the fixture (Lesson 9) manage the lifecycle, and it means tests can pass a mock request context if needed. Theprivatekeyword auto-createsthis.request— no need to manually assign it. -
payload: createTokenRequest— The parameter type comes from the schema file. This means you cannot accidentally callauthClient.createTokenApi({ user: 'admin', pass: '123' })— TypeScript will tell you the field names are wrong. -
Timing —
startTime = Date.now()before the request,duration = Date.now() - startTimeafter. Every response includes how long it took. This data goes into the test report, so you can track if endpoints are getting slower over time. -
timeout: 2_000— This is not the test timeout; it is the HTTP request timeout (2 seconds). If the server does not respond within 2 seconds, Playwright throws a network error. The test fails fast instead of hanging. -
Error checking — If the HTTP status is not 2xx, the client throws an error with a descriptive message:
"Create Token [POST] API failed.\nStatus: 401 Unauthorized". The test fails cleanly with a clear reason. -
RESTful-Booker quirk handling — The
reasonfield check is a workaround for a bug in the API: bad credentials return HTTP 200 with{ reason: "Bad credentials" }instead of a proper 401. Without this check, the test would think login succeeded and pass — the first sign of trouble would be a confusing "403 Forbidden" on a later request. -
Generic type
<T>—createTokenApi<T>lets the caller specify what type to expect the response body to be:authClient.createTokenApi<createTokenResponse>(payload). TypeScript then knows thatresponse.bodyhas a.tokenfield of typestring. -
getResponseDetails— Every client method ends by calling this utility. It returns a consistent structured object withstatus,body,headers,isResponseSuccessful,duration,url, andmethod. Every test uses the same shape for assertions.
The BookingClient manages the full CRUD lifecycle for bookings. It has more methods than AuthClient because bookings have six operations (create, get one, get all, update, partial update, delete), and some require authentication.
import { APIRequestContext } from '@playwright/test';
import { createBookingRequest, partialUpdateBookingRequest, updateBookingRequest } from './booking-schema';
import { getResponseDetails } from '../../utils/api-util';
export class BookingClient {
constructor(
private request: APIRequestContext,
private authToken?: string
) {}Why two constructor parameters?
| Parameter | Type | When It Is Available | Why Optional? |
|---|---|---|---|
request |
APIRequestContext |
Always | Every HTTP method needs it |
authToken |
string | undefined |
Only when fixture provides it | Public endpoints (POST, GET) do not need auth |
The authToken being optional is deliberate. It means:
- For public endpoints (POST, GET), you can create a client without a token:
new BookingClient(request) - For authenticated endpoints (PUT, PATCH, DELETE), the fixture provides the token automatically
- For negative tests, you can pass
''asoverrideTokento explicitly test without auth
POST — Create Booking
async createBookingApi<T>(payload: createBookingRequest) {
const startTime = Date.now();
const response = await this.request.post('/booking', {
data: payload,
timeout: 2_000,
});
const duration = Date.now() - startTime;
if (!response.ok()) {
throw new Error(`Create Booking [POST] API failed.\nStatus: ${response.status()} : ${response.statusText()}`);
}
return getResponseDetails<T>('POST', response, duration);
}Why no auth for POST? RESTful-Booker allows anyone to create a booking without authentication. This is common for booking/ordering APIs — you want customers to create bookings without needing a token.
Why payload: createBookingRequest? The parameter type comes from the Zod schema. This guarantees:
- Tests cannot accidentally omit a required field
- Tests cannot send a field with the wrong type
- The factory function (discussed below) is TypeScript-checked against the same type
GET — Single Booking
async getBookingApi<T>(bookingId: number) {
const startTime = Date.now();
const response = await this.request.get(`/booking/${bookingId}`, {
timeout: 2_000,
});
const duration = Date.now() - startTime;
if (!response.ok()) {
throw new Error(`Get Booking [GET] API failed.\nStatus: ${response.status()} ${response.statusText()}`);
}
return getResponseDetails<T>('GET', response, duration);
}Why bookingId: number? The booking ID comes from the POST response. By typing it as number, we ensure tests pass actual numbers, not strings. Playwright would URL-encode either, but the type safety catches bugs like passing a string that accidentally contains non-numeric characters.
GET — All Booking IDs (with optional filters)
async getBookingIdsApi<T>(firstname?: string, lastname?: string, checkin?: string, checkout?: string) {
const allParams = { firstname, lastname, checkin, checkout };
const filteredParams = Object.fromEntries(
Object.entries(allParams).filter(([_, value]) => value !== undefined)
) as Record<string, string>;
const startTime = Date.now();
const response = await this.request.get('/booking', {
params: filteredParams,
timeout: 2_000,
});
const duration = Date.now() - startTime;
if (!response.ok()) {
throw new Error(`Get Booking IDs [GET] API failed.\nStatus: ${response.status()} ${response.statusText()}`);
}
return getResponseDetails<T>('GET', response, duration);
}The filter logic explained step by step:
- All four parameters (
firstname,lastname,checkin,checkout) are optional (?suffix means they can beundefined) Object.entries(allParams)converts{ firstname: 'Alice', lastname: undefined, ... }to[['firstname', 'Alice'], ['lastname', undefined], ...].filter(([_, value]) => value !== undefined)removes pairs where the value is undefinedObject.fromEntries(...)converts the filtered pairs back to an object:{ firstname: 'Alice' }- Only defined parameters are sent as query string params:
GET /booking?firstname=Alice
Why this pattern instead of conditionally building the object? It is declarative — you list all possible parameters, and the filtering is automatic. Adding a new filter parameter means adding one line to the allParams object and one parameter to the method signature. No if statements needed.
PUT — Update Booking (Full Replacement)
async updateBookingApi<T>(bookingId: number, payload: updateBookingRequest, overrideToken?: string) {
const activeToken = overrideToken !== undefined ? overrideToken : this.authToken;
const startTime = Date.now();
const response = await this.request.put(`/booking/${bookingId}`, {
headers: {
...(activeToken ? { Cookie: `token=${activeToken}` } : {}),
},
data: payload,
timeout: 2_000,
});
const duration = Date.now() - startTime;
return getResponseDetails<T>('PUT', response, duration);
}The token resolution logic — why three possible values?
overrideToken |
this.authToken |
activeToken (result) |
Effect |
|---|---|---|---|
Not provided (undefined) |
"abc123" |
"abc123" |
Uses fixture-provided token — positive test |
"" (empty string) |
"abc123" |
"" |
Empty token — no Cookie header — negative test |
undefined |
undefined |
undefined |
No auth at all — negative test |
The overrideToken parameter uses overrideToken !== undefined (not just !overrideToken) so that an empty string '' is treated as a deliberate override, not as "no value provided."
The spread pattern ...(activeToken ? { Cookie: ... } : {}):
- If
activeTokenis truthy,{ Cookie: 'token=abc123' }is merged into headers - If
activeTokenis falsy (undefined, null, or ''),{}(empty object) is spread — no Cookie header at all
This avoids sending Cookie: token= with an empty value, which some servers might accept incorrectly.
PATCH — Partial Update
async partialUpdateBookingApi<T>(bookingId: number, payload: partialUpdateBookingRequest, overrideToken?: string) {
const activeToken = overrideToken !== undefined ? overrideToken : this.authToken;
const startTime = Date.now();
const response = await this.request.patch(`/booking/${bookingId}`, {
headers: {
...(activeToken ? { Cookie: `token=${activeToken}` } : {}),
},
data: payload,
timeout: 2_000,
});
const duration = Date.now() - startTime;
return getResponseDetails<T>('PATCH', response, duration);
}Same auth pattern as PUT. The critical difference is the payload type: partialUpdateBookingRequest is the Zod-generated type where every field is optional (via .partial()). This allows:
// Send only the firstname — everything else stays unchanged
await bookingClient.partialUpdateBookingApi(bookingId, { firstname: 'Alice' });If we used createBookingRequest (all fields required), TypeScript would force us to send every field — defeating the purpose of PATCH.
DELETE — Delete Booking
async deleteBookingApi<T>(bookingId: number, overrideToken?: string) {
const activeToken = overrideToken !== undefined ? overrideToken : this.authToken;
const startTime = Date.now();
const response = await this.request.delete(`/booking/${bookingId}`, {
headers: {
...(activeToken ? { Cookie: `token=${activeToken}` } : {}),
},
timeout: 2_000,
});
const duration = Date.now() - startTime;
return getResponseDetails<T>('DELETE', response, duration);
}DELETE on RESTful-Booker returns HTTP 201 (Created) with body text "Created". This is a quirk — normally DELETE returns 200 (OK) or 204 (No Content). The client does not special-case this; it passes the raw response to getResponseDetails, which handles non-JSON responses gracefully. The test then asserts on the actual response:
expect(deleteResponseDetails.status).toBe(201);
expect(deleteResponseDetails.body).toBe('Created');A factory is a function whose only job is to create test data. We separate it from the client because:
- Data generation is a separate concern — how you create a payload (randomized names, computed dates) has nothing to do with how you send it (HTTP methods, headers).
- Reusability — every test in this domain needs booking payloads. Without a factory, each test would inline its own data, leading to duplication.
- Override-ability — tests need both random data (for normal cases) and specific data (for edge cases). A factory with an
overridesparameter supports both.
The factory imports types from the schema — not from the client. This is important: the factory does not know or care about HTTP. It just returns typed objects.
import { faker } from '@faker-js/faker';
import { createBookingRequest, partialUpdateBookingRequest } from './booking-schema';
export async function generateBookingApiPayload(overrides?: createBookingRequest): Promise<createBookingRequest> {Why Promise<createBookingRequest>? The factory is async because we might eventually need async data generation (e.g., fetching a reference from the database). Making it async from the start avoids breaking changes later. The Promise wraps a createBookingRequest type — meaning the return value is guaranteed to match the schema.
Why overrides?: createBookingRequest? The overrides parameter uses the same type as the return type. This means TypeScript enforces that override values match the schema: { totalprice: 'abc' } would be caught at compile time because totalprice must be a number.
// 1. Generate Check-in (anytime in the next 30 days)
const checkIn = faker.date.soon({ days: 30 });
// 2. Generate Check-out (1 to 7 days after Check-in)
const checkOut = faker.date.soon({ days: 7, refDate: checkIn });
// 3. Format both as YYYY-MM-DD
const formatDate = (date: Date) => date.toISOString().split('T')[0];
return {
firstname: faker.person.firstName(),
lastname: faker.person.lastName(),
totalprice: faker.number.int({ min: 500, max: 2000 }),
depositpaid: faker.datatype.boolean(),
bookingdates: {
checkin: formatDate(checkIn),
checkout: formatDate(checkOut),
...overrides?.bookingdates,
},
additionalneeds: generateHotelNote(),
...overrides,
};
}Why Faker methods for each field?
| Faker Method | What It Generates | Why This Choice |
|---|---|---|
faker.person.firstName() |
Random first name (e.g., "Elena", "James") | Realistic, varied — catches encoding issues |
faker.person.lastName() |
Random last name (e.g., "Garcia", "Smith") | Same as above |
faker.number.int({ min: 500, max: 2000 }) |
Integer between 500-2000 | Realistic price range, avoids zero/negative |
faker.datatype.boolean() |
true or false |
Random deposit status — tests both paths |
faker.date.soon(...) |
Future dates | Always valid (not in the past) |
faker.helpers.arrayElement(...) |
Random selection from a list | Varied additional needs |
Why faker.date.soon for dates specifically:
faker.date.soon({ days: 30 })generates a date within the next 30 days — always in the future, never expiredfaker.date.soon({ days: 7, refDate: checkIn })generates a date 1-7 days after check-in — checkout is always after check-in- If we used
faker.date.future()for both independently, we might generate checkout before checkin
The overrides pattern in detail:
return {
// ...auto-generated fields...
bookingdates: {
checkin: formatDate(checkIn),
checkout: formatDate(checkOut),
...overrides?.bookingdates, // Step 1: override specific date fields
},
additionalneeds: generateHotelNote(),
...overrides, // Step 2: override any top-level field
};The spread order matters. ...overrides at the end means any top-level key from overrides completely replaces the auto-generated value. For nested objects like bookingdates, the inner spread ...overrides?.bookingdates lets you override just checkin while keeping the auto-generated checkout.
export function generateHotelNote() {
const requests = [
'late check-in', 'extra towels', 'high floor', 'quiet room',
'near elevator', 'king size bed', 'honeymoon package', 'vegan breakfast options',
];
return faker.helpers.arrayElement(requests);
}Why a separate function for hotel notes? It is used in both the full payload factory and the partial update factory. Extracting it avoids duplication and makes the list of possible values easy to find and edit.
The partial factory has the same structure as the full factory but with one critical difference: every field uses faker.helpers.maybe().
export async function generatePartialBookingApiPayload(
overrides?: partialUpdateBookingRequest
): Promise<partialUpdateBookingRequest> {
const checkIn = faker.date.soon({ days: 30 });
const checkOut = faker.date.soon({ days: 7, refDate: checkIn });
const formatDate = (date: Date) => date.toISOString().split('T')[0];
return {
firstname: faker.helpers.maybe(() => faker.person.firstName()),
lastname: faker.helpers.maybe(() => faker.person.lastName()),
totalprice: faker.helpers.maybe(() => faker.number.int({ min: 500, max: 2000 })),
depositpaid: faker.helpers.maybe(() => faker.datatype.boolean()),
bookingdates: faker.helpers.maybe(() => ({
checkin: formatDate(checkIn),
checkout: formatDate(checkOut),
...overrides?.bookingdates,
})),
additionalneeds: faker.helpers.maybe(() => generateHotelNote()),
...overrides,
};
}What faker.helpers.maybe() does:
faker.helpers.maybe(callback) calls the callback ~50% of the time and returns undefined the other ~50%. This means:
- A PATCH payload from this factory might include
firstnamebut notlastname - It might include
totalpriceanddepositpaidbut notbookingdates - Every invocation produces a different combination of fields
Why this matters: PATCH is designed to send only the fields you want to change. By randomly including or omitting each field, our tests simulate real-world PATCH usage — a mobile app might patch just the firstname, while an admin panel might patch everything at once.
The critical cleanup step:
const cleanPatchRequestPayload = JSON.parse(JSON.stringify(patchRequestPayload));When faker.helpers.maybe() returns undefined, the object has keys with undefined values: { firstname: "Alice", lastname: undefined }. This is valid JavaScript but not valid JSON — JSON.stringify removes those keys:
JSON.stringify({ firstname: "Alice", lastname: undefined })
// Result: '{"firstname":"Alice"}' — lastname is gone
Then JSON.parse(...) converts it back to a clean object: { firstname: "Alice" }. Without this step, Playwright would send { "firstname": "Alice", "lastname": null } (JSON.stringify converts undefined to null in arrays) or omit the field — behavior that differs between runtimes.
Why the return type is partialUpdateBookingRequest:
The type partialUpdateBookingRequest (via createBookingRequestSchema.partial()) allows every field to be optional. If we returned createBookingRequest (all fields required), TypeScript would error because the factory might omit any field. The type and the factory are perfectly matched.
With all three layers in place (schema, client, factory), tests become remarkably clean. Let us trace exactly how each import feeds into the next:
import { test, expect } from '../src/fixtures/api-fixture';
import { generateBookingApiPayload } from '../src/api/booking/booking-factory';
import { BookingClient } from '../src/api/booking/booking-client';
import { createBookingResponse } from '../src/api/booking/booking-schema';
test('[POST] Create Booking', async ({ request }) => {Notice what is imported and what is not:
| Imported | Not Imported | Why |
|---|---|---|
generateBookingApiPayload (factory) |
Raw faker calls |
The test does not care how data is generated — just that it gets valid data |
BookingClient (client) |
request.get() / request.post() |
The test does not make raw HTTP calls — the client encapsulates all HTTP logic |
createBookingResponse (type from schema) |
z.object() / z.string() |
The test uses the TypeScript type for IDE autocomplete, but validation happens via safeParse in the validation step |
// Step 1: Create a client — no auth needed for POST
const bookingClient = new BookingClient(request);
// Step 2: Generate random, schema-compliant payload
const payload = await generateBookingApiPayload();
// Step 3: Send request via client — client handles timing, error checking, logging
const response = await bookingClient.createBookingApi<createBookingResponse>(payload);The generic <createBookingResponse> tells TypeScript: "the response.body should look like a createBookingResponse." This means response.body.bookingid is known to be a number, and response.body.booking.firstname is known to be a string.
// Step 4: Assert on the structured response
expect(response.status).toBe(200);
expect(response.body.booking.firstname).toBe(payload.firstname);
});What the test NO LONGER does:
- No
request.post()with raw options - No manual
response.json()parsing - No
response.headers()checks for Content-Type - No error handling for non-2xx responses
- No timing measurement
- No JSON.stringify for debug logging
All of that is in the client and utilities. The test stays at the intent level: "generate data, send request, check result."
This test from the actual framework (tests/create-booking-typesafety.api.spec.ts) shows the complete pattern including Zod validation and test.step():
import { test, expect } from '../src/fixtures/api-fixture';
import { generateBookingApiPayload } from '../src/api/booking/booking-factory';
import { BookingClient } from '../src/api/booking/booking-client';
import { createBookingResponse, createBookingResponseSchema } from '../src/api/booking/booking-schema';
import { stringifyJson } from '../src/utils/api-util';
import { z } from 'zod';
test('[POST] Create Booking', async ({ request }, testInfo) => {
const bookingClient = new BookingClient(request);
const requestPayload = await generateBookingApiPayload();
await testInfo.attach('POST API REQUEST', {
body: stringifyJson(requestPayload),
contentType: 'application/json',
});
const responseDetails = await bookingClient.createBookingApi<createBookingResponse>(requestPayload);
await test.step('Validation', async () => {
// 1. Status code
expect(responseDetails.status, 'Status should be 200').toBe(200);
expect(responseDetails.isResponseSuccessful, 'Should be Success Status Code').toBe(true);
// 2. Headers
expect(responseDetails.headers['content-type']).toContain('application/json');
// 3. Individual field assertions
expect(responseDetails.body.booking.firstname).toBe(requestPayload.firstname);
expect(responseDetails.body.booking.totalprice).toBe(requestPayload.totalprice);
// 4. Zod schema validation — validates the ENTIRE response shape at once
const result = createBookingResponseSchema.safeParse(responseDetails.body);
expect(result.success,
`Schema Validation:\n${!result.success ? z.prettifyError(result.error) : ''}`
).toBeTruthy();
// 5. Bulk match
expect(responseDetails.body).toMatchObject({
bookingid: expect.any(Number),
booking: {
firstname: requestPayload.firstname,
totalprice: requestPayload.totalprice,
},
});
});
});Each validation layer catches different things:
- Status code — "was the request accepted?"
- Schema validation — "does the response have the correct structure?"
- Individual fields — "do the specific values match what we sent?"
- Bulk match — "is the overall shape as expected?"
The pattern is consistent across every API domain. To add a new domain (e.g., room):
Step 1: Create src/api/room/room-schema.ts
- Define Zod schemas for every request and response shape
- Export inferred types with
z.infer - Reuse existing schemas where possible
Step 2: Create src/api/room/room-client.ts
- Define a class that takes
APIRequestContextin the constructor - Create one method per API endpoint
- Use types from the schema file for all parameters
- Delegate to
getResponseDetailsfor response handling
Step 3: Create src/api/room/room-factory.ts (if the endpoint creates data)
- Import types from the schema file
- Use Faker methods to generate random values
- Support an
overridesparameter for edge case testing
Step 4: Write tests in tests/
- Import the client, factory, and types
- Follow the same pattern: generate → send → assert
The consistency of this pattern means that after building the Booking domain, adding a new domain is mechanical — you already know exactly what each file needs to contain.
Time: 35 min
A fixture is a reusable piece of test setup. Playwright gives you built-in fixtures like request, page, browser, and context. But you can also define custom fixtures that encapsulate your own setup logic.
Why fixtures instead of manual setup?
| Manual Setup | Fixtures |
|---|---|
Each test must call new BookingClient(request) |
Fixture creates it automatically |
| Each test must authenticate and manage tokens | Fixture handles auth once per worker |
| No automatic cleanup | Fixtures support setup/teardown via use() |
| No type safety | Fixtures are typed — IDE knows what is available |
| Hard to share between files | Import once, use everywhere |
A fixture is a function that:
- Takes dependencies from other fixtures (composable)
- Performs setup (login, create client, etc.)
- Passes the result to the test via
use() - Optionally performs cleanup after the test finishes
base.extend<MyFixtures>({
myFixture: async ({ request }, use) => {
// Setup: runs before the test
const result = await doSetup(request);
// Pass to test: test uses `myFixture` as a parameter
await use(result);
// Teardown: runs after the test
await doCleanup(result);
},
});// src/fixtures/api-fixture.ts
import { test as base } from '@playwright/test';
import { BookingClient } from '../api/booking/booking-client';
import { AuthClient } from '../api/auth/auth-client';
import { createTokenResponse } from '../api/auth/auth-schema';
type ApiFixtures = {
bookingClient: BookingClient;
authToken: string;
};
export const test = base.extend<ApiFixtures>({
authToken: async ({ request }, use) => {
const authClient = new AuthClient(request);
// Perform login
const responseDetails = await authClient.createTokenApi<createTokenResponse>({
username: process.env.AUTH_USERNAME || 'admin',
password: process.env.AUTH_PASSWORD || 'password123',
});
// Share the authToken with the test
await use(responseDetails.body.token);
},
bookingClient: async ({ request, authToken }, use) => {
const client = new BookingClient(request, authToken);
// Pre-test logic could go here
await use(client);
// Post-test logic could go here
},
});
export { expect } from '@playwright/test';How this works, step by step:
-
base.extend<ApiFixtures>({...})— Creates a newtestinstance that includes our custom fixtures. This newtestinherits all built-in fixtures (request,page,browser, etc.) plus our defined ones. -
authTokenfixture:- Takes the built-in
requestfixture as a dependency - Creates an AuthClient using that request context
- Calls
createTokenApiwith credentials (from env vars or fallbacks) - The
await use(...)call pauses the fixture and passes the token value to any test that requestsauthToken - When the test finishes, execution returns here (for teardown, if needed)
- Takes the built-in
-
bookingClientfixture:- Takes two dependencies:
request(built-in) andauthToken(our custom fixture — this demonstrates fixture composition) - Creates a
BookingClientwith the request context and the authenticated token - Passes the client to the test
- Playwright ensures
authTokenfixture runs beforebookingClientbecausebookingClientdepends onauthToken
- Takes two dependencies:
Lazy creation: Fixtures are only created when a test actually requests them. If a test only uses request, the authToken and bookingClient fixtures never run — saving time.
Tests import test from the fixture file, not from @playwright/test:
import { test, expect } from '../src/fixtures/api-fixture';
// Positive test — fixture provides the auth token
test('[PUT] Update Booking - Valid Token', { tag: ['@positive'] }, async ({ bookingClient }) => {
const response = await bookingClient.updateBookingApi(bookingId, putRequestPayload);
// bookingClient already has authToken injected — no manual auth needed
expect(response.status).toBe(200);
});
// Negative test — override token with empty string
test('[PUT] Update Booking - No Token', { tag: ['@negative'] }, async ({ bookingClient }) => {
const response = await bookingClient.updateBookingApi(bookingId, putRequestPayload, '');
expect(response.status).toBe(403);
});The override pattern is critical for negative testing:
- Positive test: uses the fixture-provided token (valid)
- Negative test: passes
''asoverrideToken, which bypasses the fixture token entirely
The fixture file includes a commented-out alternative:
// If using global-setup.ts, just declare the authToken here from env
// Remove the loginAuthToken fixture if you will implement this
// const loginAuthToken = process.env.API_GLOBAL_TOKEN;The global-setup.ts (in src/api/global/) is an alternative approach:
import { request } from '@playwright/test';
import { AuthClient } from '../auth/auth-client';
async function globalSetup() {
const requestContext = await request.newContext({
baseURL: 'https://restful-booker.herokuapp.com',
});
const authClient = new AuthClient(requestContext);
try {
const authResponseDetails = await authClient.createTokenApi({
username: process.env.API_USERNAME || 'admin',
password: process.env.API_PASSWORD || 'password123',
});
process.env.AUTH_GLOBAL_TOKEN = authResponseDetails.body.token;
console.log('Successfully authenticated globally.');
} catch (error) {
console.error('Global authentication failed!', error);
process.exit(1);
}
}
export default globalSetup;To use it, add this line inside defineConfig({...}) in playwright.config.ts:
export default defineConfig({
globalSetup: require.resolve('./src/api/global/global-setup'),
testDir: './tests',
// ...
});The difference: global setup runs once before all tests, storing the token in process.env. Fixtures run per worker (once per parallel worker), keeping the token in memory. The fixture approach is simpler and more common for API testing.
Credentials, API URLs, and configuration values change between environments (local, dev, staging, production). Hardcoding them means editing code when you switch environments or when secrets need to rotate.
dotenv solves this by reading a .env file and loading each line into process.env:
# .env
AUTH_USERNAME="admin"
AUTH_PASSWORD="password123"After dotenv.config(), your code can read:
process.env.AUTH_USERNAME // "admin"
process.env.AUTH_PASSWORD // "password123"Step 1: Create .env in the project root (this file is gitignored — never committed):
AUTH_USERNAME="admin"
AUTH_PASSWORD="password123"Step 2: Create .env.template as a committed reference (no real secrets):
AUTH_USERNAME=""
AUTH_PASSWORD=""Anyone cloning the project copies .env.template to .env and fills in their own values.
Step 3: Load dotenv in playwright.config.ts:
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env') });This must be at the top of the config file (before any tests run). path.resolve(__dirname, '.env') ensures the correct path regardless of where Playwright runs from.
The authToken fixture already uses env vars with fallbacks:
username: process.env.AUTH_USERNAME || 'admin',
password: process.env.AUTH_PASSWORD || 'password123',The || fallback means:
- If
.envexists and has the variable → use it - If
.envdoes not exist → use the default values (admin/password123)
This is intentional: the RESTful-Booker demo API uses these exact default credentials, so the tests work out of the box without any configuration.
- Local:
.envfile is read by dotenv - CI: Environment variables are set natively (GitHub Actions Secrets, Jenkins Credentials).
process.envworks the same way in both — dotenv only populatesprocess.envif the.envfile exists
Time: 45 min
Playwright's mocking capabilities are not just for UI tests. You can intercept, modify, and replay API responses even when using a browser. These tests use the page fixture (browser) because Playwright's route interception works at the browser network level.
Run with
--headed: Since these tests use a browser, run them with--headedto see the fruits list update visually:npx playwright test --project=api-tests --headed. The browser window will show the mocked or modified fruit list.
test('mocks a fruit and does not call api', async ({ page }) => {
// Intercept the API call before the browser makes it
await page.route('**/api/v1/fruits', async (route) => {
// Provide fake response data
const json = [
{ name: 'Pineapple', id: 100 },
{ name: 'Papaya', id: 101 },
];
// Fulfill the request with fake data (no network call)
await route.fulfill({ json });
});
// Navigate to the page
await page.goto('https://demo.playwright.dev/api-mocking');
// Assert that the mocked fruits appear on screen
await expect(page.getByText('Pineapple')).toBeVisible();
await expect(page.getByText('Papaya')).toBeVisible();
});How it works:
page.route('**/api/v1/fruits', handler)— Registers a route handler that intercepts any request matching the URL pattern- The handler function receives the intercepted
routeobject route.fulfill({ json })— Sends a fake response to the browser. The browser never connects to the real server- The page renders using the mocked data
test('gets the json from api and adds a new fruit', async ({ page }) => {
await page.route('**/api/v1/fruits', async (route) => {
// Let the real API respond
const response = await route.fetch();
const json = await response.json();
// Add a new fruit to the real response
json.push({ name: 'Jackfruit', id: 100 });
// Fulfill with the modified response
await route.fulfill({ response, json });
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Jackfruit', { exact: true })).toBeVisible();
});How it works:
route.fetch()— Proxies the request to the real server and returns the real response- The real JSON data is extracted and modified (new fruit added)
route.fulfill({ response, json })— Sends the modified data back to the browser, preserving original response headers from the real response
This is useful for testing UI behavior with specific API states — for example, simulating what happens when an API returns an empty array, an error, or specific data.
HAR (HTTP Archive) is a standard file format for recording HTTP traffic. Playwright can record real API interactions and replay them offline.
test.describe.configure({ mode: 'serial' }); // Ensure tests run in order
test('records or updates the HAR file', async ({ page }) => {
await page.routeFromHAR('./hars/fruits.har', {
url: '**/api/v1/fruits',
update: true, // Record mode
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Strawberry')).toBeVisible();
});
test('gets the json from HAR and checks the new fruit has been added', async ({ page }) => {
await page.routeFromHAR('./hars/fruits.har', {
url: '**/api/v1/fruits',
update: false, // Replay mode
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Strawberry')).toBeVisible();
});How it works:
-
First run (record) —
update: true:- Playwright intercepts requests matching
**/api/v1/fruits - Fetches the real response from the server
- Saves the request + response to
./hars/fruits.har - The browser gets the real data
- Playwright intercepts requests matching
-
Subsequent runs (replay) —
update: false:- Playwright intercepts matching requests
- Serves the recorded response from the HAR file
- No network call is made — tests run offline
-
mode: 'serial'— Ensures the recording test runs before the replay test. Without this, parallel workers might replay before recording completes.
Why HAR matters:
- Tests run without network dependency (faster, more reliable)
- Responses are deterministic (same data every run)
- Useful for demo/training environments where the real API might not be available
These mocking tests use page (browser) rather than request (API client) because page.route() operates on browser network traffic. They combine API mocking with UI verification — the mock happens at the network level, and the test verifies that the UI renders correctly with the mock data.
Time: 45 min
Allure is an open-source reporting framework that transforms test results into a rich, interactive HTML dashboard. It shows:
- Timeline view — when each test ran, how long it took
- Categories — passed, failed, broken, skipped
- Graphs — test duration distribution, status breakdown
- Per-test details — steps, attachments, parameters, error messages
Add the Allure reporter to playwright.config.ts:
export default defineConfig({
reporter: [
['list'], // Console output during run
['html', { outputFolder: 'playwright-report', open: 'on-failure' }], // Playwright HTML report
['junit', { outputFile: 'test-results/junit-results.xml' }], // JUnit XML for CI integration
['allure-playwright', { resultsDir: 'allure-results' }], // Allure results
],
});Multiple reporters work simultaneously. Run tests, then serve the Allure report:
npx playwright test
npx allure serve allure-resultsThe getResponseDetails utility (Lesson 7) calls test.info().attach() on every failed response. Allure automatically picks up these attachments and displays them in the failure details. This means every test failure includes the full request and response data for debugging.
The final playwright.config.ts brings everything together:
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env') });
export default defineConfig({
// Global Setup (optional) — uncomment to use global-setup.ts
// globalSetup: require.resolve('./src/api/global/global-setup'),
testDir: './tests',
// Run all tests in parallel
fullyParallel: true,
// Prevent committing test.only
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Single worker on CI (parallelism can cause issues in limited CI environments)
workers: process.env.CI ? 1 : undefined,
// Reporters
reporter: [
['html', { outputFolder: 'playwright-report', open: process.env.CI ? 'never' : 'on-failure' }],
['list'],
['junit', { outputFile: 'test-results/junit-results.xml' }],
['allure-playwright', { resultsDir: 'allure-results' }],
],
use: {
baseURL: 'https://restful-booker.herokuapp.com',
extraHTTPHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
trace: 'retain-on-failure',
},
projects: [
{
name: 'api-tests',
testMatch: /.*\.api\.spec\.ts$/,
},
],
});Key settings explained:
| Setting | Value | Purpose |
|---|---|---|
fullyParallel: true |
True | API tests are independent — run them in parallel for speed |
forbidOnly: !!process.env.CI |
CI detection | Blocks test.only from being committed (would skip all other tests) |
retries: process.env.CI ? 2 : 0 |
2 in CI, 0 locally | Retry flaky tests in CI (rare for API tests, but good safety) |
workers: process.env.CI ? 1 : undefined |
1 in CI | Use a single worker in CI (avoids rate limiting / resource contention) |
trace: 'retain-on-failure' |
On failure only | Captures a network trace that can be viewed in Playwright Trace Viewer |
testMatch: /.*\.api\.spec\.ts$/ |
Pattern | Only runs files ending in .api.spec.ts within the api-tests project |
This section is optional. Running tests locally is already a real achievement. CI/CD is a "what's next" step.
Create .github/workflows/test-workflow.yml:
name: Playwright Test
on: [push, pull_request, workflow_dispatch]
jobs:
test:
runs-on: ubuntu-latest
permissions:
checks: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- run: npm ci
- run: npx playwright install chromium
- run: npx playwright test
env:
CI: 'true'
- uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: JUnit Test Results
path: test-results/junit-results.xml
reporter: java-junit
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-full
path: playwright-report/
retention-days: 30Pipeline steps explained:
actions/checkout@v4— Pulls the code from GitHubactions/setup-node@v4— Installs Node.js with npm caching for faster installsnpm ci— Clean install (fails ifpackage-lock.jsonis out of date withpackage.json)npx playwright install chromium— Installs the browser (needed for mocking tests)npx playwright test— Runs all tests withCI: 'true'(affects retry/config behavior)dorny/test-reporter@v1— Publishes JUnit results as GitHub Checksactions/upload-artifact@v4— Saves the HTML report as a build artifact (downloadable from GitHub)
Create Jenkinsfile at the project root:
pipeline {
agent any
tools {
nodejs 'NodeJS_LTS_24.16.0'
allure 'Allure_CMD_2.40.0'
}
stages {
stage('Prepare .env') {
environment {
SECRETS_ENV = credentials('playwright-api-testing-secrets')
}
steps {
bat '''
if exist .env del .env
copy "%SECRETS_ENV%" .env
'''
}
}
stage('Install Dependencies') { steps { bat 'call npm ci --no-audit --no-fund' } }
stage('Install Chromium') { steps { bat 'call npx playwright install chromium' } }
stage('Run Tests') { steps { bat 'call npx playwright test' } }
}
post {
always {
bat 'if exist .env del .env'
junit 'test-results/junit-results.xml'
publishHTML(target: [
allowMissing: false,
alwaysLinkToLastBuild: true,
reportDir: 'playwright-report',
reportFiles: 'index.html',
reportName: 'Playwright HTML Report'
])
allure([
includeProperties: false,
reportBuildPolicy: 'ALWAYS',
results: [[path: 'allure-results']]
])
}
}
}Key points:
- Secrets management: Jenkins credentials are copied to
.env, then deleted inpost { always }to prevent secrets from persisting on disk npm ci: Strict install — fails if dependencies mismatch the lockfilecallprefix: Required in Windowsbatsteps to run batch files correctly- Multiple reports: JUnit (for test trends), HTML (for browsing), and Allure (for interactive dashboards)
Time: 30 min
playwright-api-testing/
├── src/
│ ├── api/
│ │ ├── auth/
│ │ │ ├── auth-client.ts — HTTP client for POST /auth
│ │ │ └── auth-schema.ts — Zod schemas for auth
│ │ ├── booking/
│ │ │ ├── booking-client.ts — HTTP client for booking CRUD
│ │ │ ├── booking-schema.ts — Zod schemas for all booking endpoints
│ │ │ └── booking-factory.ts — Faker payload generators
│ │ └── global/
│ │ └── global-setup.ts — Alternative global auth (optional)
│ ├── fixtures/
│ │ └── api-fixture.ts — Custom fixtures (authToken, bookingClient)
│ └── utils/
│ └── api-util.ts — Response helpers (getResponseDetails, stringifyJson, formatApiRequest)
├── tests/ — All test specs (*.api.spec.ts)
│ ├── ping.api.spec.ts
│ ├── create-booking-static.spec.ts
│ ├── create-booking-dynamic.spec.ts
│ ├── create-booking-faker.spec.ts
│ ├── create-booking-typesafety.api.spec.ts
│ ├── get-booking.api.spec.ts
│ ├── get-booking-ids.api.spec.ts
│ ├── update-booking.api.spec.ts
│ ├── partial-update-booking.api.spec.ts
│ ├── delete-booking.api.spec.ts
│ └── api-mocking.api.spec.ts
├── test-data/
│ ├── static-booking-data.json
│ └── dynamic-booking-data.json
├── hars/ — Recorded HAR files for mocking
├── .github/workflows/ — GitHub Actions CI workflow
├── .vscode/settings.json — Editor settings (Prettier on save)
├── .prettierrc — Prettier formatting rules
├── .env.template — Template env vars (committed)
├── .env — Local env vars (gitignored)
├── Jenkinsfile — Jenkins Pipeline
├── playwright.config.ts — Central configuration
├── package.json — Dependencies
| Test File | What It Tests | Pattern |
|---|---|---|
ping.api.spec.ts |
Health check GET /ping returns 201 | Simplest possible test |
create-booking-static.spec.ts |
POST /booking with hardcoded JSON | Static data, inline getResponseDetails, testInfo.attach, test.step |
create-booking-dynamic.spec.ts |
POST /booking with template placeholders | Dynamic JSON, formatApiRequest utility |
create-booking-faker.spec.ts |
POST /booking with random data | Faker-generated values via templates |
create-booking-typesafety.api.spec.ts |
POST /booking with full framework | API Object Model, Zod schemas, factory function |
get-booking.api.spec.ts |
GET /booking/{id} | Chained requests (POST then GET), Zod validation |
get-booking-ids.api.spec.ts |
GET /booking with filters | Query params, array assertions, toContainEqual |
update-booking.api.spec.ts |
PUT /booking/{id} | Positive test (valid token) + negative test (no token) |
partial-update-booking.api.spec.ts |
PATCH /booking/{id} | Positive + negative, partial factory with maybe(), cleanPatchRequestPayload |
delete-booking.api.spec.ts |
DELETE /booking/{id} | Auth fixture, status 201 assertion |
api-mocking.api.spec.ts |
Mocking + HAR | page.route, route.fulfill, routeFromHAR |
-
API Object Model — One folder per API domain. Three files per domain:
*-schema.tsfor structure,*-client.tsfor HTTP,*-factory.tsfor data generation. -
Three data strategies — Start with static JSON (easy to debug), graduate to dynamic templates (flexible), settle on Faker factory functions (production-ready).
-
Zod over manual checks — Define schemas once. Get both TypeScript types AND runtime validation. Use
safeParsefor non-throwing validation. -
Centralized response handling —
getResponseDetailsin every client method. Consistent logging, timing measurement, and failure reporting with automatic test report attachments. -
Fixture-based auth —
authTokenlogs in once per worker.bookingClientinjects the token automatically. Tests never think about authentication. -
Override pattern — Client methods accept
overrideTokenfor negative tests. Passing''explicitly removes auth to test 403 responses. -
test.step()for organized validation — Group assertions under named steps for better report readability and easier debugging. -
testInfo.attach()for request/response logging — Attach the full request payload and response body to the test report. Viewable in both Playwright HTML report and Allure. -
Faker partial data —
faker.helpers.maybe()for PATCH payloads. Each field has ~50% chance of inclusion, matching PATCH semantics. Always clean withJSON.parse(JSON.stringify(...))to remove undefined values. -
Chain requests — POST to create data, capture the ID, then GET/PUT/PATCH/DELETE with the resulting ID. Always own your test data.
-
Tag-based filtering — Use
{ tag: ['@positive'] }and{ tag: ['@negative'] }on tests. Run subsets with--grep "@positive". -
Secrets cleanup — Delete
.envin Jenkinspost { always }. Never leave credentials on disk. -
toBeOK()for quick status checks — Useexpect(response).toBeOK()when you just need any 2xx status. Use.toBe(200)for exact status.
| Command | What it does |
|---|---|
npx playwright test |
Run all tests across all projects |
npx playwright test --project=api-tests |
Run API tests only |
npx playwright test --grep "@positive" |
Run positive tests only (tagged) |
npx playwright test --grep "@negative" |
Run negative tests only (tagged) |
npx playwright test tests/ping.api.spec.ts |
Run a single file |
npx playwright test --debug |
Run with Playwright Inspector |
npx playwright show-report |
Open Playwright HTML report |
npx allure serve allure-results |
Open Allure report |
npm install && npx playwright install chromium |
Clean setup (when cloning the repo) |
You now have a complete, production-ready API test framework. Here are ideas for extending it:
- Add more API domains — Following the same three-file pattern (schema, client, factory)
- Data-driven tests — Use
test.each()or parameterized test data - Contract testing — Compare API responses against OpenAPI/Swagger specs
- Performance baseline — Track response times and fail if they exceed thresholds
- Integrate with reporting dashboards — Publish Allure reports to a web server
- Add more CI/CD pipelines — GitLab CI, CircleCI, Azure DevOps