Skip to content

Latest commit

 

History

History
1298 lines (1029 loc) · 44.9 KB

File metadata and controls

1298 lines (1029 loc) · 44.9 KB

Tutorial: Playwright API Testing from Scratch

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.

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? — 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 page object — you use request (Playwright's APIRequestContext)
    • 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)
  • 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!)

Lesson 2 — Setting Up the Environment

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

    1. Go to https://nodejs.org (the LTS version is recommended)
    2. Download the Windows installer (.msi) and run it
    3. Follow the installer — leave all defaults checked
    4. After installation, restart your terminal so the new PATH takes effect
  • Install an editor — you need a code editor. Two good free options:

  • 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 data
    • zod — runtime JSON schema validation
    • dotenv — loads environment variables from .env
    • allure-playwright + allure-commandline — Allure reporting
  • What just happened? — the scaffold + install created:

    • package.json — lists all dependencies
    • playwright.config.ts — central configuration file
    • tests/ — folder for test files
    • node_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

Lesson 3 — What Did That Command Create?

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.ts or *.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 JS
    • strict: true — catches more errors at compile time
    • include — 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.


Lesson 4 — Your First API Test

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 }) => { — the request fixture is Playwright's HTTP client
    • request.get(...) — sends a GET request, returns an APIResponse object
    • response.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 in playwright.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 APIResponse object key properties:

    • response.status() — HTTP status code (200, 201, 403, etc.)
    • response.statusText() — "OK", "Created", "Forbidden"
    • response.ok()true if status is 200-299
    • response.headers() — response headers object
    • response.json() — parse body as JSON
    • response.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.ts with a single test that hits GET /ping and asserts status 201. Run with --project=api-tests.


Lesson 5 — API Test Runner Basics

Time: 30 min

  • test() — defines a test case. Each test gets the request fixture 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. Without await the 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.


Lesson 6 — Three Data Strategies

Time: 55 min
Install: @faker-js/faker (already installed)

This project demonstrates three distinct strategies for providing test data. Each has its place.

Strategy 1: Static JSON Files

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);
});

Strategy 2: Dynamic JSON Templates

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);
});

Strategy 3: Faker-Generated Data (Recommended)

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

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

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 API
    • z.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();

    safeParse returns { 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.

Part 2 — Response Utility Helpers

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.

Part 3 — Assertions for API Testing

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


Lesson 8 — API Object Model

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

Auth API

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

Booking API

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

  • authToken is optional in the constructor — the client works for public endpoints without auth
  • overrideToken parameter 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 getResponseDetails for consistent logging

Booking Factory

// 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,
  };
}
  • overrides parameter lets you pin specific fields for edge case testing
  • generatePartialBookingApiPayload uses faker.helpers.maybe() — each field has ~50% chance of inclusion, matching PATCH semantics

The Test Becomes Clean

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:
    1. *-schema.ts — Zod schemas + TypeScript types
    2. *-client.ts — HTTP client methods
    3. *-factory.ts — Faker payload generation (if it creates data)

Lesson 9 — Custom Fixtures & Environment Variables

Time: 35 min

Part 1 — Custom Fixtures

  • 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 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);
        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:

    1. authToken fixture calls /auth, gets a token, and passes it to the test
    2. bookingClient fixture depends on request + authToken — creates an authenticated BookingClient
    3. Both are lazy — only created when a test lists them as parameters
  • In tests — import test from 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 overrideToken parameter lets negative tests inject empty/invalid tokens while positive tests use the fixture token transparently.

Part 2 — Environment Variables (dotenv)

Credentials and config values change between environments. Hardcoding means editing code per environment.

  • Why dotenv? — reads a .env file and loads each line into process.env.

  • Setup:

    1. Create .env in the project root:

      AUTH_USERNAME="admin"
      AUTH_PASSWORD="password123"

      Add .env to .gitignore.

    2. Create .env.template as a committed reference:

      AUTH_USERNAME=""
      AUTH_PASSWORD=""
    3. 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 authToken fixture already uses them with || fallbacks:

    username: process.env.AUTH_USERNAME || 'admin',
    password: process.env.AUTH_PASSWORD || 'password123',

    The fallback means tests run without a .env file. In CI, native environment variables override them.

  • CI vs Local: .env is local-only. CI sets AUTH_USERNAME and AUTH_PASSWORD as pipeline secrets. process.env works the same way in both.


Lesson 10 — API Mocking

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.

1. Full Mock — No Real API Call

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.

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

3. HAR File Recording and Replay

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: true records real API responses to .hars/fruits.har

  • Subsequent runs with update: false replay 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).


Lesson 11 — Reporting, Config & CI/CD

Time: 45 min

Part 1 — Allure Reporting

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

    The Allure dashboard shows: timeline view, categories (passed/failed/broken), graphs, and per-test details including failure attachments.

  • Automatic failure attachmentsgetResponseDetails calls test.info().attach(...) on every failed response. Allure picks these up automatically.

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({
  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 parallel
  • forbidOnly: !!process.env.CI — blocks test.only from being committed
  • retries: process.env.CI ? 2 : 0 — retry only in CI (flaky tests)
  • workers: process.env.CI ? 1 : undefined — single worker in CI
  • trace: 'retain-on-failure' — captures network trace on failure

Part 3 — CI/CD (Optional)

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

Jenkins PipelineJenkinsfile 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 .env at build time, deleted in post { always }
  • npm ci — strict install (fails if lockfile is out of date)
  • JUnit, HTML, and Allure reports published post-build

Lesson 12 — Full Framework & Running Tests

Time: 30 min

Final Project Structure

├── 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

Best Practices Recap

  • API Object Model: one folder per API domain; *-client.ts for HTTP, *-schema.ts for validation, *-factory.ts for 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: getResponseDetails in every client — consistent logging, timing, failure reporting
  • Fixture-based auth: authToken logs in once per worker; bookingClient injects the token automatically
  • Override pattern: client methods accept overrideToken for 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 .env in Jenkins post { always }

Running Tests

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)