Target audience: Complete beginners — no Playwright, no TypeScript knowledge assumed.
Format: Short, focused lessons with one takeaway each. Libraries installed incrementally as they're introduced.
API under test: RESTful-Booker — a free, public hotel booking API.
| Lesson | Topic | Time |
|---|---|---|
| 1 | What is Playwright & API Testing? | 15 min |
| 2 | Environment Setup | 30 min |
| 3 | What Did That Command Create? | 20 min |
| 4 | Your First API Test | 45 min |
| 5 | API Test Runner Basics | 30 min |
| 6 | Three Data Strategies | 55 min |
| 7 | Validating Responses: Zod, Utilities & Assertions | 55 min |
| 8 | API Object Model | 60 min |
| 9 | Custom Fixtures & Environment Variables | 35 min |
| 10 | API Mocking | 45 min |
| 11 | Reporting, Config & CI/CD | 45 min |
| 12 | Full Framework & Running Tests | 30 min |
| Total | ~7.5 hours |
Time: 15 min
-
What is Playwright? — an open-source tool from Microsoft. Most people know it for browser automation, but Playwright has a first-class API testing feature built in. You send HTTP requests (GET, POST, PUT, PATCH, DELETE) directly without a browser at all. Official docs: https://playwright.dev/docs/api-testing
-
Why automate API tests?
- APIs are the backbone of modern apps — if the API breaks, the UI breaks too
- API tests run in milliseconds (no browser rendering)
- They're more stable than UI tests — no flaky locators, no timeouts waiting for animations
- You catch backend contract breaks before any UI tests run
-
What's different from UI testing?
- No
pageobject — you userequest(Playwright'sAPIRequestContext) - No locators, no clicks — you send requests and validate JSON responses
- No browser needed — but you still install Chromium (Playwright needs it internally for some utilities)
- No
-
What you'll build — by the end of this course, you'll have a professional API test framework with:
- API Object Model — one client class per API domain (auth, booking)
- Faker — randomized test data generation
- Zod — runtime schema validation for responses
- Three data strategies — static JSON, dynamic JSON templates, and Faker-generated
- Custom fixtures — automatic auth token injection
- API mocking — request interception and HAR file replay
- Allure reporting — rich, interactive test reports
- CI/CD (optional) — GitHub Actions + Jenkins (running locally is already a win!)
Time: 30 min
Install: Node.js, npm, VS Code or Cursor
-
What is Node.js? — a JavaScript runtime. It lets you run JavaScript on your computer (not just in a browser). Playwright is a Node.js library.
-
What is npm? — Node Package Manager. It downloads and manages libraries (dependencies) for your project.
-
Install Node.js and npm (if not already installed):
- Go to https://nodejs.org (the LTS version is recommended)
- Download the Windows installer (.msi) and run it
- Follow the installer — leave all defaults checked
- After installation, restart your terminal so the new PATH takes effect
-
Install an editor — you need a code editor. Two good free options:
- VS Code — download from https://code.visualstudio.com
- Cursor — download from https://www.cursor.com (built on VS Code with AI features)
-
Verify everything is installed — open a terminal and run:
node --version # should show v20 or higher npm --version # should show 10 or higher
-
Create the project:
mkdir playwright-api-testing cd playwright-api-testing npm init playwright@latest- Select TypeScript: Yes
- Tests folder: keep default
tests/ - GitHub Actions: No — it's optional, you'll have everything running locally first
- Install browsers: Yes (needed for API mocking tests)
-
Install project dependencies — once the scaffold is done, install the extra libraries we'll need:
npm install -D @faker-js/faker zod dotenv allure-playwright allure-commandline
@faker-js/faker— generates realistic random test datazod— runtime JSON schema validationdotenv— loads environment variables from.envallure-playwright+allure-commandline— Allure reporting
-
What just happened? — the scaffold + install created:
package.json— lists all dependenciesplaywright.config.ts— central configuration filetests/— folder for test filesnode_modules/— downloaded libraries (don't touch this)- Browsers installed in a system cache
-
Open in your editor:
- VS Code: run
code .in the project folder - Cursor: run
cursor .in the project folder
- VS Code: run
Time: 20 min
After npm init playwright@latest, you'll see these files/folders:
-
playwright.config.ts— the main configuration file. It tells Playwright where your tests are, reporters, and CI settings. -
package.json— the project manifest. Lists dependencies and scripts you can run. -
node_modules/— downloaded libraries. Never edit manually. Add to.gitignore. -
tests/— where Playwright looks for test files (*.spec.tsor*.test.ts). -
playwright-report/— generated after a test run; contains the HTML report. -
tsconfig.json(optional) — TypeScript configuration. Create it at the project root for type-checking:{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./out", "types": ["node"] }, "include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"], "exclude": ["node_modules"] }Key settings:
target: ES2022,module: commonjs— compiles modern TypeScript to Node-compatible JSstrict: true— catches more errors at compile timeinclude— which files TypeScript type-checks
-
.gitignore— tells Git which files/folders to ignore:node_modules/ /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ allure-results/ .env hars/Generated folders are rebuilt when tests run. Secrets (
.env) stay local. -
src/— you'll create this folder for your source code: API clients, schemas, factories, fixtures, and utilities.
Time: 45 min
In UI testing you get a page object. In API testing you get a request object — Playwright's APIRequestContext. It sends real HTTP requests without launching a browser.
-
The simplest API test — a GET request to the healthcheck endpoint:
// tests/healthcheck.spec.ts 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 it line by line:
test('API is reachable', async ({ request }) => {— therequestfixture is Playwright's HTTP clientrequest.get(...)— sends a GET request, returns anAPIResponseobjectresponse.status()— the HTTP status code (201 means "Created" for this API's ping)expect(response.status()).toBe(201)— assert the status
-
Set
baseURL— instead of writing the full URL in every test, set it inplaywright.config.ts: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$/, }, ], });
extraHTTPHeaders— sent with every request (never repeat headers)testMatch: /.*\.api\.spec\.ts$/— only runs files ending with.api.spec.ts- Now tests can use relative paths:
await request.get('/ping')
-
Run it:
npx playwright test --project=api-tests -
Read the response body:
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
APIResponseobject key properties:response.status()— HTTP status code (200, 201, 403, etc.)response.statusText()— "OK", "Created", "Forbidden"response.ok()—trueif status is 200-299response.headers()— response headers objectresponse.json()— parse body as JSONresponse.text()— parse body as plain text
-
Playwright VS Code extension — install "Playwright Test for VS Code" by Microsoft. Adds a testing sidebar where you can run/debug individual tests with one click.
-
Demo: create
tests/ping.api.spec.tswith a single test that hits GET/pingand asserts status 201. Run with--project=api-tests.
Time: 30 min
-
test()— defines a test case. Each test gets therequestfixture automatically:import { test, expect } from '@playwright/test'; 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); });
-
test.describe()— groups related tests together:test.describe('Booking CRUD', () => { test('create booking', async ({ request }) => { ... }); test('get booking', async ({ request }) => { ... }); test('delete booking', async ({ request }) => { ... }); });
-
async/await— every API call is asynchronous. Withoutawaitthe test passes before the response arrives:// Wrong: test('wrong', ({ request }) => { request.get('/booking'); // fires but never awaited }); // Right: test('right', async ({ request }) => { const response = await request.get('/booking'); });
-
expect()basics — makes an assertion. Common API matchers:expect(response.status()).toBe(200); // exact status code expect(response.ok()).toBeTruthy(); // any 2xx expect(response.statusText()).toBe('OK'); // status text expect(response.headers()['content-type']).toContain('application/json');
-
Chaining requests — API tests often need multiple requests. Create data first, then use the resulting ID:
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 const getRes = await request.get(`/booking/${bookingId}`); expect(getRes.status()).toBe(200); });
-
Demo: write a file with 2 passing tests and 1 intentional failure (e.g., assert status 404 on a non-existent booking) to see how failures are reported.
Time: 55 min
Install: @faker-js/faker (already installed)
This project demonstrates three distinct strategies for providing test data. Each has its place.
Hardcoded JSON data stored in files. Best for known, repeatable scenarios.
// 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"
}
}// tests/create-booking-static.spec.ts
import { test, expect } from '@playwright/test';
import requestData from '../test-data/static-booking-data.json';
test('[POST] Create Booking using Static Data', async ({ request }) => {
const response = await request.post('/booking', {
data: requestData.validBooking,
timeout: 10_000,
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.booking.firstname).toBe('John');
expect(body.booking.totalprice).toBe(1000);
});Use a JSON file with {0}, {1} placeholders and replace them at runtime.
// test-data/dynamic-booking-data.json
{
"firstname": "{0}",
"lastname": "{1}",
"totalprice": "{2}",
"depositpaid": "{3}",
"bookingdates": {
"checkin": "{4}",
"checkout": "{5}"
},
"additionalneeds": "{6}"
}// src/utils/api-util.ts — the formatApiRequest function
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}"`;
});
}// tests/create-booking-dynamic.spec.ts
import { test, expect } from '@playwright/test';
import { formatApiRequest } from '../src/utils/api-util';
import fs from 'fs';
import path from 'path';
test('[POST] Create Booking using Dynamic Data', async ({ request }) => {
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);
const response = await request.post('/booking', { data: requestData, timeout: 10_000 });
expect(response.status()).toBe(200);
});Use @faker-js/faker to generate randomized, realistic data on every test run.
import { faker } from '@faker-js/faker';
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];
const values = [
faker.person.firstName(),
faker.person.lastName(),
faker.number.int({ min: 500, max: 2000 }),
faker.datatype.boolean(),
formatDate(checkIn),
formatDate(checkOut),
generateHotelNote(),
];The flow is the same as Strategy 2 (template + values), but now values are randomized. Every run produces different booking data.
- Progression: start with Strategy 1 (static — easy to debug), try Strategy 2 (templates — flexible), settle on Strategy 3 (Faker — realistic). The finished framework uses Faker with factory functions (Lesson 8).
Time: 55 min
This lesson ties together three things that work as one: defining response shapes (Zod), logging responses consistently (Utilities), and asserting on them (Assertions).
When an API returns JSON, you need to verify its shape — not just "is status 200?" but "does the response have the right fields with the right types?".
-
What is Zod? — a TypeScript-first schema library. You define a schema once, get both TypeScript types AND runtime validation.
// src/api/booking/booking-schema.ts import { z } from 'zod'; export const BookingDatesSchema = z.object({ checkin: z.string(), checkout: z.string(), }); export const createBookingRequestSchema = z.object({ firstname: z.string(), lastname: z.string(), totalprice: z.number(), depositpaid: z.boolean(), bookingdates: BookingDatesSchema, additionalneeds: z.string().optional(), }); export type createBookingRequest = z.infer<typeof createBookingRequestSchema>; export const createBookingResponseSchema = z.object({ bookingid: z.number(), booking: createBookingRequestSchema, }); export type createBookingResponse = z.infer<typeof createBookingResponseSchema>;
Key points:
z.object({...})— defines an object with typed fields.optional()— field may be omitted by the APIz.infer<typeof schema>— extracts the TypeScript type (no manual interface needed)- Schemas for GET, PUT, PATCH responses reuse
createBookingRequestSchema
-
Validate at runtime:
const result = createBookingResponseSchema.safeParse(body); expect(result.success, `Schema Validation:\n${!result.success ? z.prettifyError(result.error) : ''}` ).toBeTruthy();
safeParsereturns{ success: true, data: ... }or{ success: false, error: ... }. It never throws. -
Reusing schemas for other endpoints:
export const getBookingResponseSchema = createBookingRequestSchema; export const partialUpdateBookingRequestSchema = createBookingRequestSchema.partial();
PATCH uses
.partial()— every field becomes optional.
Raw response.json() works, but professional frameworks need structured logging, timing, and failure reports.
-
getResponseDetails— centralized response handler used by every API client:// src/utils/api-util.ts 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; 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; } const responseLog = { url: response.url(), method, duration: `${duration}ms`, status: response.status(), statusText: response.statusText(), response: { headers: response.headers(), body: parsedBody }, }; const formattedLog = stringifyJson(responseLog); 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); } return { url: response.url(), method, duration: `${duration}ms`, status: response.status(), statusText: response.statusText(), isResponseSuccessful: response.ok(), headers: response.headers(), body: parsedBody, }; } export function stringifyJson(object: Record<string, any>): string { return JSON.stringify(object, null, 2); }
What it does:
- Safely parses JSON based on
Content-Type - Measures request duration
- Attaches failures to the Playwright HTML report as searchable JSON
- Logs successes to stdout
- Returns a structured object with
status,body,headers,isResponseSuccessful, etc.
- Safely parses JSON based on
With Zod schemas and getResponseDetails 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');
-
Response body assertions:
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— checks specific fields match, ignoring extras: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, }), }, });
-
Schema validation as an assertion:
const result = createBookingResponseSchema.safeParse(responseDetails.body); expect(result.success, `Schema Validation:\n${!result.success ? z.prettifyError(result.error) : ''}` ).toBeTruthy();
-
Header assertions:
expect(responseDetails.headers['content-type']).toContain('application/json');
-
Negative tests — assert that unauthorized requests fail correctly:
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'); });
-
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 expect.objectContaining()Partial matches inside nested objects Asymmetric Matcher expect.any()Ignoring dynamic values (IDs, dates) Type check -
Demo: write a POST test that uses a Faker payload, calls the API, asserts status + headers + individual fields +
toMatchObject+ Zod schema validation, all usinggetResponseDetails.
Time: 60 min
This is the API equivalent of Page Object Model (POM). Instead of one class per page, you create one class per API domain. Each domain gets a folder with three files:
src/api/
auth/
auth-schema.ts — Zod schemas + TypeScript types
auth-client.ts — HTTP communication
booking/
booking-schema.ts — Zod schemas + TypeScript types
booking-client.ts — HTTP communication
booking-factory.ts — Faker payload generation
// src/api/auth/auth-schema.ts
import { z } from 'zod';
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>;// src/api/auth/auth-client.ts
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" field
if (responseData.reason) {
throw new Error(`Authentication Failed! Server returned reason: "${responseData.reason}".`);
}
return getResponseDetails<T>('POST', response, duration);
}
}Note the RESTful-Booker quirk: bad credentials return HTTP 200 with { reason: "Bad credentials" } instead of a proper 401. The client catches this and throws a clear error.
// src/api/booking/booking-client.ts
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 // Injected by fixture (Lesson 9)
) {}
// POST — no auth needed
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);
}
// GET single booking — no auth needed
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);
}
// GET all booking IDs — with optional query 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);
}
// PUT — requires auth token as Cookie header
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);
}
// PATCH — same auth pattern
async partialUpdateBookingApi<T>(...) { ... }
// DELETE — same auth pattern
async deleteBookingApi<T>(...) { ... }
}Key design decisions:
authTokenis optional in the constructor — the client works for public endpoints without authoverrideTokenparameter lets tests pass any value (including'') for negative tests- Token injection uses
Cookie: token=...because RESTful-Booker doesn't use Bearer auth ...(activeToken ? { Cookie: ... } : {})avoids sending an empty Cookie header- All methods delegate to
getResponseDetailsfor consistent logging
// src/api/booking/booking-factory.ts
import { faker } from '@faker-js/faker';
import { createBookingRequest, partialUpdateBookingRequest } from './booking-schema';
export async function generateBookingApiPayload(overrides?: createBookingRequest): Promise<createBookingRequest> {
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.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,
};
}
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);
}
export async function generatePartialBookingApiPayload(overrides?: partialUpdateBookingRequest) {
return {
firstname: faker.helpers.maybe(() => faker.person.firstName()),
lastname: faker.helpers.maybe(() => faker.person.lastName()),
// ... same pattern for all fields — each randomly included or omitted
...overrides,
};
}overridesparameter lets you pin specific fields for edge case testinggeneratePartialBookingApiPayloadusesfaker.helpers.maybe()— each field has ~50% chance of inclusion, matching PATCH semantics
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 }) => {
const bookingClient = new BookingClient(request);
const payload = await generateBookingApiPayload();
const response = await bookingClient.createBookingApi<createBookingResponse>(payload);
expect(response.status).toBe(200);
expect(response.body.booking.firstname).toBe(payload.firstname);
});- How to add a new API endpoint — create three files under a new folder:
*-schema.ts— Zod schemas + TypeScript types*-client.ts— HTTP client methods*-factory.ts— Faker payload generation (if it creates data)
Time: 35 min
-
What is a fixture? — a reusable piece of test setup. Playwright gives you built-in ones (
request,page,browser). Custom fixtures let you add your own. -
Why fixtures over manual setup?
- Lazy — only created when a test requests them
- Composable — fixtures can depend on each other
- Auto-cleanup — setup/teardown via
use() - Type-safe — your IDE knows what's available
-
The
authTokenandbookingClientfixtures:// 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); const responseDetails = await authClient.createTokenApi<createTokenResponse>({ username: process.env.AUTH_USERNAME || 'admin', password: process.env.AUTH_PASSWORD || 'password123', }); await use(responseDetails.body.token); }, bookingClient: async ({ request, authToken }, use) => { const client = new BookingClient(request, authToken); await use(client); }, }); export { expect } from '@playwright/test';
How it works:
authTokenfixture calls/auth, gets a token, and passes it to the testbookingClientfixture depends onrequest+authToken— creates an authenticatedBookingClient- Both are lazy — only created when a test lists them as parameters
-
In tests — import
testfrom the fixture file, not from@playwright/test:import { test, expect } from '../src/fixtures/api-fixture'; test('PUT with valid token', async ({ bookingClient }) => { // bookingClient already has authToken injected const response = await bookingClient.updateBookingApi(bookingId, payload); }); test('PUT with no token', async ({ bookingClient }) => { // Override token with empty string for 403 negative test const response = await bookingClient.updateBookingApi(bookingId, payload, ''); });
-
Fixture override pattern — the
overrideTokenparameter lets negative tests inject empty/invalid tokens while positive tests use the fixture token transparently.
Credentials and config values change between environments. Hardcoding means editing code per environment.
-
Why dotenv? — reads a
.envfile and loads each line intoprocess.env. -
Setup:
-
Create
.envin the project root:AUTH_USERNAME="admin" AUTH_PASSWORD="password123"
Add
.envto.gitignore. -
Create
.env.templateas a committed reference:AUTH_USERNAME="" AUTH_PASSWORD=""
-
Load dotenv in
playwright.config.ts:import dotenv from 'dotenv'; import path from 'path'; dotenv.config({ path: path.resolve(__dirname, '.env') });
-
-
Using env vars in fixtures — the
authTokenfixture already uses them with||fallbacks:username: process.env.AUTH_USERNAME || 'admin', password: process.env.AUTH_PASSWORD || 'password123',
The fallback means tests run without a
.envfile. In CI, native environment variables override them. -
CI vs Local:
.envis local-only. CI setsAUTH_USERNAMEandAUTH_PASSWORDas pipeline secrets.process.envworks the same way in both.
Time: 45 min
Playwright's mocking capabilities aren't just for UI tests. You can intercept, modify, and replay API responses even when using a browser.
test('mocks a fruit and does not call api', async ({ page }) => {
await page.route('**/api/v1/fruits', async (route) => {
const json = [
{ name: 'Pineapple', id: 100 },
{ name: 'Papaya', id: 101 },
];
await route.fulfill({ json });
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Pineapple')).toBeVisible();
await expect(page.getByText('Papaya')).toBeVisible();
});page.route() intercepts matching requests. route.fulfill() sends a fake response — the browser never hits the real API.
test('gets the json from api and adds a new fruit', async ({ page }) => {
await page.route('**/api/v1/fruits', async (route) => {
const response = await route.fetch(); // Let the real API respond
const json = await response.json();
json.push({ name: 'Jackfruit', id: 100 }); // Modify the response
await route.fulfill({ response, json });
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Jackfruit', { exact: true })).toBeVisible();
});route.fetch() proxies the request to the real server. You get the real response, modify it, and fulfill with the patched data.
HAR (HTTP Archive) files record real API traffic for offline replay.
test.describe.configure({ mode: 'serial' });
test('records or updates the HAR file', async ({ page }) => {
await page.routeFromHAR('./hars/fruits.har', {
url: '**/api/v1/fruits',
update: true, // Record mode — captures real responses
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Strawberry')).toBeVisible();
});
test('gets the json from HAR', async ({ page }) => {
await page.routeFromHAR('./hars/fruits.har', {
url: '**/api/v1/fruits',
update: false, // Replay mode — serves from HAR file
});
await page.goto('https://demo.playwright.dev/api-mocking');
await expect(page.getByText('Strawberry')).toBeVisible();
});-
First run with
update: truerecords real API responses to.hars/fruits.har -
Subsequent runs with
update: falsereplay from the HAR — no network dependency -
mode: 'serial'ensures recording runs before replay -
HAR files are gitignored — each developer records their own
-
Why these tests use
page(browser):page.route()operates on browser network traffic. These tests combine API mocking with UI verification (the page renders data fetched from an API).
Time: 45 min
-
What is Allure? — an open-source reporting framework that produces detailed HTML reports with timelines, categories, graphs, and test history.
-
Add the Allure reporter in
playwright.config.ts:export default defineConfig({ reporter: [ ['list'], ['html', { outputFolder: 'playwright-report', open: process.env.CI ? 'never' : 'on-failure' }], ['junit', { outputFile: 'test-results/junit-results.xml' }], ['allure-playwright', { resultsDir: 'allure-results' }], ], });
-
View the report:
npx playwright test npx allure serve allure-resultsThe Allure dashboard shows: timeline view, categories (passed/failed/broken), graphs, and per-test details including failure attachments.
-
Automatic failure attachments —
getResponseDetailscallstest.info().attach(...)on every failed response. Allure picks these up automatically.
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({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
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:
fullyParallel: true— API tests are independent, run in parallelforbidOnly: !!process.env.CI— blockstest.onlyfrom being committedretries: process.env.CI ? 2 : 0— retry only in CI (flaky tests)workers: process.env.CI ? 1 : undefined— single worker in CItrace: 'retain-on-failure'— captures network trace on failure
Congratulations — you've already built a fully functional API testing framework that runs beautifully on your machine, and that's a real achievement! This section is an extra bonus for when you're ready to take things further. Setting up CI/CD is a great "what's next" step, but feel free to skip it for now if you just want to enjoy what you've built.
GitHub Actions — .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: 30Jenkins Pipeline — Jenkinsfile at 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' // Clean up secrets
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']]
])
}
}
}- Secrets are managed via Jenkins credential files, copied to
.envat build time, deleted inpost { always } npm ci— strict install (fails if lockfile is out of date)- JUnit, HTML, and Allure reports published post-build
Time: 30 min
├── src/
│ ├── api/
│ │ ├── auth/
│ │ │ ├── auth-client.ts — HTTP client for /auth endpoint
│ │ │ └── auth-schema.ts — Zod schemas for auth request/response
│ │ └── booking/
│ │ ├── booking-client.ts — HTTP client for booking CRUD
│ │ ├── booking-schema.ts — Zod schemas for all booking endpoints
│ │ └── booking-factory.ts — Faker payload generators
│ ├── fixtures/
│ │ └── api-fixture.ts — Custom fixtures (authToken, bookingClient)
│ └── utils/
│ └── api-util.ts — Response helpers (getResponseDetails, stringifyJson)
├── tests/ — All test specs (*.api.spec.ts)
├── test-data/ — Static JSON and dynamic JSON templates
├── 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 config
└── package.json — Dependencies
- API Object Model: one folder per API domain;
*-client.tsfor HTTP,*-schema.tsfor validation,*-factory.tsfor data generation - Three data strategies: start static, graduate to template-based, settle on Faker-generated factories
- Zod over manual checks: define schemas once, get TypeScript types + runtime validation
- Centralized response handling:
getResponseDetailsin every client — consistent logging, timing, failure reporting - Fixture-based auth:
authTokenlogs in once per worker;bookingClientinjects the token automatically - Override pattern: client methods accept
overrideTokenfor negative tests - Faker partial data:
faker.helpers.maybe()for PATCH payloads — each field randomly included or omitted - Chain requests when needed: POST to create data, then GET/PUT/PATCH/DELETE with the resulting ID
- CI/CD (optional): GitHub Actions + Jenkins with JUnit, HTML, and Allure reports — local runs are already a win
- Secrets cleanup: delete
.envin Jenkinspost { always }
| Command | What it does |
|---|---|
npx playwright test |
Run all tests |
npx playwright test --project=api-tests |
Run API tests only |
npx playwright test --grep "@positive" |
Run positive tests only |
npx playwright test tests/ping.api.spec.ts |
Run a single file |
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) |