Skip to content

Latest commit

 

History

History
2900 lines (2198 loc) · 118 KB

File metadata and controls

2900 lines (2198 loc) · 118 KB

Tutorial: Playwright API Testing from Scratch

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.

Table of Contents

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

Lesson 1 — What is Playwright & API Testing?

Time: 15 min

What is Playwright?

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

Why Automate API Tests?

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.

What is Different from UI Testing?

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.

What You Will Build

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 Under Test: RESTful-Booker

The API we will test throughout this tutorial is RESTful-Booker, a free, publicly hosted hotel booking API. It supports:

  • Health checkGET /ping returns 201 if the API is alive
  • AuthenticationPOST /auth returns a token for write operations
  • Create BookingPOST /booking creates a new booking (no auth needed)
  • Get BookingGET /booking/{id} retrieves a single booking (no auth)
  • Get Booking IDsGET /booking lists all bookings, with optional name/date filters (no auth)
  • Update BookingPUT /booking/{id} replaces an entire booking (auth required)
  • Partial UpdatePATCH /booking/{id} updates specific fields only (auth required)
  • Delete BookingDELETE /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.


Lesson 2 — Setting Up the Environment

Time: 30 min Install: Node.js, npm, VS Code or Cursor

What is Node.js?

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.

What is npm?

npm stands for Node Package Manager. It has two jobs:

  1. Download and install libraries (called "packages" or "dependencies") for your project
  2. Manage which versions of those libraries your project uses

When you run npm install something, npm:

  1. Contacts the npm registry (a massive online database of JavaScript libraries)
  2. Downloads the library and its dependencies
  3. Places them in a node_modules/ folder in your project
  4. Records the library name and version in package.json

Install Node.js and npm

If Node.js is not installed on your machine:

  1. Go to https://nodejs.org
  2. Download the LTS (Long Term Support) Windows installer (.msi) — LTS versions are stable and recommended for most users
  3. Run the installer and leave all defaults checked
  4. Restart your terminal after installation so the new PATH environment variable takes effect (this lets your terminal find the node and npm commands)

Choose an Editor

You need a code editor to write your test files:

Either works fine for this tutorial. We will also install the Playwright Test for VS Code extension in Lesson 4.

Verify Installation

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 the Project

Create a new folder for the project and initialize Playwright inside it:

mkdir playwright-api-testing
cd playwright-api-testing
npm init playwright@latest

The 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 dependency
  • playwright.config.ts — central configuration
  • tests/ — a folder for test files (with an example spec)
  • A browser installation (Chromium) in a system cache

Install Additional Libraries

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

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

What Just Happened?

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

Open in Your Editor

  • VS Code: code . (from the project folder)
  • Cursor: cursor . (from the project folder)

You should see the folder structure in the editor's sidebar.


Lesson 3 — What Did That Command Create?

Time: 20 min

When you ran npm init playwright@latest, it generated several files and folders. Let us examine each one in detail.

playwright.config.ts

This is the most important file in the project after your test files themselves. It tells Playwright:

  1. Where your tests are — the testDir option (default: ./tests)
  2. Which tests to run — the testMatch pattern in each project
  3. What settings to use — base URL, headers, timeouts, retries
  4. What reporters to use — how to display results (HTML, list, JUnit, Allure)
  5. 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.

package.json

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 use npx playwright test directly.
  • devDependencies — all the libraries we installed. The ^ caret means "allow minor and patch updates." For example, ^10.4.0 means version 10.4.0 or any 10.x.x higher.
  • type: "commonjs" — tells Node.js to use CommonJS module system (require() instead of import). Playwright handles TypeScript compilation internally, so this does not affect our .ts files.

node_modules/

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.

tests/

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.

playwright-report/

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

.gitignore

This 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

src/

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

Lesson 4 — Your First API Test

Time: 45 min

The request Fixture

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.

The Simplest API Test

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:

  1. import { test, expect } from '@playwright/test' — Imports Playwright's test function (to define test cases) and expect (to make assertions). These are the only two imports most tests need.

  2. test('API is reachable', async ({ request }) => { ... }) — Defines a named test case. The async keyword is required because API calls are asynchronous. The { request } destructures the request fixture from the test context — Playwright creates this fixture for you.

  3. const response = await request.get(...) — Sends an HTTP GET request to the specified URL. The await keyword pauses the test until the response arrives. Without await, the test would continue executing before the server responds.

  4. expect(response.status()).toBe(201) — Asserts that the HTTP status code equals 201. RESTful-Booker's /ping endpoint returns HTTP 201 (Created) rather than the more common 200 (OK) to indicate the service is alive.

Configuring baseURL

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. So request.get('/ping') becomes GET https://restful-booker.herokuapp.com/ping.
  • extraHTTPHeaders — These headers are sent with every request automatically. Content-Type: application/json tells the server we are sending JSON. Accept: application/json tells the server we want JSON back. No need to repeat these in every request call.
  • projects — Defines a project called api-tests that 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);
});

Run the Test

npx playwright test --project=api-tests

Breaking this down:

  • npx — Executes a Node.js package without installing it globally. It looks for Playwright in node_modules/.bin/.
  • playwright test — Invokes Playwright's test runner.
  • --project=api-tests — Only runs tests matching the api-tests project 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)

The APIResponse Object

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 redirects

Read the Response Body

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

Playwright VS Code Extension

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.

Demo Task

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

You should see a passing test. If it fails, check:

  1. Is the RESTful-Booker API reachable? Try curl https://restful-booker.herokuapp.com/ping in a terminal.
  2. Is baseURL set correctly in the config?
  3. Did you name the file with .api.spec.ts?

Lesson 5 — API Test Runner Basics

Time: 30 min

The test() Function

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-Length header automatically
  • You do not need to call JSON.stringify() — Playwright handles it

Grouping Tests with test.describe()

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

Understanding async/await

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 await must be declared async
  • Any function that calls an async function should either await it or use .then()
  • Playwright request methods (get(), post(), put(), patch(), delete()) are all async
  • response.json() and response.text() are also async

Basic Assertions with expect()

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 inclusion

For 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' })

Chaining Requests

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.

Demo Task

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

You will see:

  • 3 passing tests: the 2 from demo.api.spec.ts plus the 1 from ping.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.


Lesson 6 — Three Data Strategies

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.

Strategy 1: Static JSON Files

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

Strategy 2: Dynamic JSON Templates

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 utilitysrc/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 testtests/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.

Strategy 3: Faker-Generated Data (Recommended)

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:

  1. Prevents collisions (two test runs creating the same booking)
  2. Uncovers edge cases (a developer might hardcode "John" and miss that the API fails on "José")
  3. Feels more like real-world usage

Create the testtests/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.

Progression Path

  1. Start with static JSON — Easy to debug, understand, and get working
  2. Try dynamic templates — Learn the placeholder pattern, understand how data flows from template to request
  3. Settle on Faker with factory functions — The production-ready approach we use in the finished framework (Lesson 8)

Lesson 7 — Validating Responses: Zod, Utilities & Assertions

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

Part 1 — Zod Schema Validation

The Problem Zod Solves

When you test an API, there are two critical moments where data structure matters:

  1. 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.
  2. Receiving a response — you need to verify the API returned the shape you expect. Did it include bookingid? Is totalprice a 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 Zod

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?"

The Dual Role of Zod Schemas

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.

Booking Schemas — src/api/booking/booking-schema.ts

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 string
  • z.number() — must be a number
  • z.boolean() — must be a boolean
  • z.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' }); // OK

This means the TypeScript type partialUpdateBookingRequest automatically reflects that every field might be present or absent — exactly matching PATCH behavior.

Validating at Runtime

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

The Full Picture: How Zod Flows Through the Framework

                    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.

Part 2 — Response Utility Helpers

Raw response.json() works, but professional frameworks need structured logging, timing, and failure reporting.

getResponseDetailssrc/utils/api-util.ts

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:

  1. Reads the raw response textresponse.text() reads the body once. From here we can parse JSON safely, avoiding the "body already consumed" error.
  2. 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.
  3. Safe JSON parsing — Wrapped in try/catch. If the API returns malformed JSON, the test gets a descriptive error message instead of crashing.
  4. Builds a structured log — Contains the URL, method, duration, status, headers, and body.
  5. Smart reporting — Failed responses are attached to the Playwright HTML report as searchable JSON attachments. Successful responses are logged to stdout.
  6. Returns structured data — The calling code gets a consistent object with status, body, headers, isResponseSuccessful, etc.

formatApiRequest — Dynamic Template Helper

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

Part 3 — Assertions for API Testing

Once Zod schemas and getResponseDetails are in place, assertions become clean and consistent.

Status Code Assertions

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

Response Body Assertions

// 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');

Bulk Validation with toMatchObject

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

Schema Validation as an Assertion

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.

Header Assertions

expect(responseDetails.headers['content-type']).toContain('application/json');

Negative Tests

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.

Array Assertions

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 Summary Reference

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

Lesson 8 — API Object Model

Time: 60 min

What is API Object Model?

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

Why Three Files, Not One?

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.

Why Does the Schema Come First?

The schema file is the foundation of every domain. Before you write any HTTP code or data generation, you must answer:

  1. What fields does this API endpoint expect? (request schema)
  2. What fields does this API endpoint return? (response schema)
  3. Which fields are required vs optional?
  4. 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 suggests firstname, lastname, totalprice, etc.

How the Three Files Work Together — The Complete Data Flow

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

What Stays in the Test vs What Goes in the Files

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)

Auth API

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.

Schema — src/api/auth/auth-schema.ts

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:

  • createTokenRequest type — ensures the fixture and any test that creates a token passes { username: string, password: string }, not reversed or misspelled fields
  • createTokenResponse type — lets the fixture extract responseDetails.body.token with full type safety (the fixture knows body has a token field that is a string)

Auth API

Schema — src/api/auth/auth-schema.ts

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.

Client — src/api/auth/auth-client.ts

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:

  1. constructor(private request: APIRequestContext) — The constructor takes Playwright's request fixture 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. The private keyword auto-creates this.request — no need to manually assign it.

  2. payload: createTokenRequest — The parameter type comes from the schema file. This means you cannot accidentally call authClient.createTokenApi({ user: 'admin', pass: '123' }) — TypeScript will tell you the field names are wrong.

  3. TimingstartTime = Date.now() before the request, duration = Date.now() - startTime after. 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.

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

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

  6. RESTful-Booker quirk handling — The reason field 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.

  7. 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 that response.body has a .token field of type string.

  8. getResponseDetails — Every client method ends by calling this utility. It returns a consistent structured object with status, body, headers, isResponseSuccessful, duration, url, and method. Every test uses the same shape for assertions.

Booking API

Client — src/api/booking/booking-client.ts

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 '' as overrideToken to 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:

  1. All four parameters (firstname, lastname, checkin, checkout) are optional (? suffix means they can be undefined)
  2. Object.entries(allParams) converts { firstname: 'Alice', lastname: undefined, ... } to [['firstname', 'Alice'], ['lastname', undefined], ...]
  3. .filter(([_, value]) => value !== undefined) removes pairs where the value is undefined
  4. Object.fromEntries(...) converts the filtered pairs back to an object: { firstname: 'Alice' }
  5. 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 activeToken is truthy, { Cookie: 'token=abc123' } is merged into headers
  • If activeToken is 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');

Booking Factory — src/api/booking/booking-factory.ts

What is a Factory and Why Do We Need One?

A factory is a function whose only job is to create test data. We separate it from the client because:

  1. 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).
  2. Reusability — every test in this domain needs booking payloads. Without a factory, each test would inline its own data, leading to duplication.
  3. Override-ability — tests need both random data (for normal cases) and specific data (for edge cases). A factory with an overrides parameter 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 expired
  • faker.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.

Partial Update Factory

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 firstname but not lastname
  • It might include totalprice and depositpaid but not bookingdates
  • 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 JSONJSON.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.

The Test Becomes Clean — Tracing the Full Data Flow

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

Full Framework Test Example

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?"

How to Add a New API Domain

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 APIRequestContext in the constructor
  • Create one method per API endpoint
  • Use types from the schema file for all parameters
  • Delegate to getResponseDetails for 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 overrides parameter 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.


Lesson 9 — Custom Fixtures & Environment Variables

Time: 35 min

Part 1 — Custom Fixtures

What is a Fixture?

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

Fixture Lifecycle

A fixture is a function that:

  1. Takes dependencies from other fixtures (composable)
  2. Performs setup (login, create client, etc.)
  3. Passes the result to the test via use()
  4. 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);
  },
});

The authToken and bookingClient Fixtures

// 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:

  1. base.extend<ApiFixtures>({...}) — Creates a new test instance that includes our custom fixtures. This new test inherits all built-in fixtures (request, page, browser, etc.) plus our defined ones.

  2. authToken fixture:

    • Takes the built-in request fixture as a dependency
    • Creates an AuthClient using that request context
    • Calls createTokenApi with credentials (from env vars or fallbacks)
    • The await use(...) call pauses the fixture and passes the token value to any test that requests authToken
    • When the test finishes, execution returns here (for teardown, if needed)
  3. bookingClient fixture:

    • Takes two dependencies: request (built-in) and authToken (our custom fixture — this demonstrates fixture composition)
    • Creates a BookingClient with the request context and the authenticated token
    • Passes the client to the test
    • Playwright ensures authToken fixture runs before bookingClient because bookingClient depends on authToken

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.

Using Fixtures in Tests

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 '' as overrideToken, which bypasses the fixture token entirely

Alternative: Global Setup

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.

Part 2 — Environment Variables (dotenv)

Why dotenv?

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"

Setup

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.

Using Environment Variables

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 .env exists and has the variable → use it
  • If .env does 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.

CI vs Local

  • Local: .env file is read by dotenv
  • CI: Environment variables are set natively (GitHub Actions Secrets, Jenkins Credentials). process.env works the same way in both — dotenv only populates process.env if the .env file exists

Lesson 10 — API Mocking

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 --headed to see the fruits list update visually: npx playwright test --project=api-tests --headed. The browser window will show the mocked or modified fruit list.

1. Full Mock — No Real API Call

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:

  1. page.route('**/api/v1/fruits', handler) — Registers a route handler that intercepts any request matching the URL pattern
  2. The handler function receives the intercepted route object
  3. route.fulfill({ json }) — Sends a fake response to the browser. The browser never connects to the real server
  4. The page renders using the mocked data

2. Intercept and Modify

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:

  1. route.fetch() — Proxies the request to the real server and returns the real response
  2. The real JSON data is extracted and modified (new fruit added)
  3. 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.

3. HAR File Recording and Replay

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:

  1. 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
  2. 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
  3. 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

Important Note

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.


Lesson 11 — Reporting, Config & CI/CD

Time: 45 min

Part 1 — Allure Reporting

What is Allure?

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

Setup

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

Automatic Failure Attachments

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

Part 2 — Final Config

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

Part 3 — CI/CD (Optional)

This section is optional. Running tests locally is already a real achievement. CI/CD is a "what's next" step.

GitHub Actions

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: 30

Pipeline steps explained:

  1. actions/checkout@v4 — Pulls the code from GitHub
  2. actions/setup-node@v4 — Installs Node.js with npm caching for faster installs
  3. npm ci — Clean install (fails if package-lock.json is out of date with package.json)
  4. npx playwright install chromium — Installs the browser (needed for mocking tests)
  5. npx playwright test — Runs all tests with CI: 'true' (affects retry/config behavior)
  6. dorny/test-reporter@v1 — Publishes JUnit results as GitHub Checks
  7. actions/upload-artifact@v4 — Saves the HTML report as a build artifact (downloadable from GitHub)

Jenkins Pipeline

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 in post { always } to prevent secrets from persisting on disk
  • npm ci: Strict install — fails if dependencies mismatch the lockfile
  • call prefix: Required in Windows bat steps to run batch files correctly
  • Multiple reports: JUnit (for test trends), HTML (for browsing), and Allure (for interactive dashboards)

Lesson 12 — Full Framework & Running Tests

Time: 30 min

Final Project Structure

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 Files Summary

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

Best Practices Recap

  1. API Object Model — One folder per API domain. Three files per domain: *-schema.ts for structure, *-client.ts for HTTP, *-factory.ts for data generation.

  2. Three data strategies — Start with static JSON (easy to debug), graduate to dynamic templates (flexible), settle on Faker factory functions (production-ready).

  3. Zod over manual checks — Define schemas once. Get both TypeScript types AND runtime validation. Use safeParse for non-throwing validation.

  4. Centralized response handlinggetResponseDetails in every client method. Consistent logging, timing measurement, and failure reporting with automatic test report attachments.

  5. Fixture-based authauthToken logs in once per worker. bookingClient injects the token automatically. Tests never think about authentication.

  6. Override pattern — Client methods accept overrideToken for negative tests. Passing '' explicitly removes auth to test 403 responses.

  7. test.step() for organized validation — Group assertions under named steps for better report readability and easier debugging.

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

  9. Faker partial datafaker.helpers.maybe() for PATCH payloads. Each field has ~50% chance of inclusion, matching PATCH semantics. Always clean with JSON.parse(JSON.stringify(...)) to remove undefined values.

  10. Chain requests — POST to create data, capture the ID, then GET/PUT/PATCH/DELETE with the resulting ID. Always own your test data.

  11. Tag-based filtering — Use { tag: ['@positive'] } and { tag: ['@negative'] } on tests. Run subsets with --grep "@positive".

  12. Secrets cleanup — Delete .env in Jenkins post { always }. Never leave credentials on disk.

  13. toBeOK() for quick status checks — Use expect(response).toBeOK() when you just need any 2xx status. Use .toBe(200) for exact status.

Running Tests

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)

What's Next?

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