Lecture 1
Lecture 1 . CRUD with the request fixture

CRUD with the request fixture

The Playwright request fixture is a full HTTP client living inside @playwright/test. This lecture walks through the lifecycle, sets a baseURL, authenticates against restful-booker, walks the four CRUD verbs, contrasts PUT and PATCH, and finishes with storage state reuse and retry / backoff patterns.

01The request fixture@playwright/test

request is one of the built-in fixtures Playwright injects into every test. You destructure it from the test args and call it like a small fetch wrapper. Unlike browser navigation, there is no page, no rendering, no JavaScript execution - just HTTP.

tests/intro.api.spec.ts
import { test, expect } from '@playwright/test';

test('request fixture is a thin HTTP client', async ({ request }) => {
  const res = await request.get('https://restful-booker.herokuapp.com/ping');
  expect(res.ok()).toBeTruthy();
  expect(res.status()).toBe(201);
  const text = await res.text();
  expect(text).toContain('Created');
});

The eight methods on APIRequestContext: get, post, put, patch, delete, head, fetch (verb agnostic), and dispose (used when you manage contexts yourself).

flowchart LR
  T[test()] --> RFX[request fixture]
  RFX --> CTX[APIRequestContext]
  CTX --> M{verb}
  M --> G[get]
  M --> P[post]
  M --> PU[put]
  M --> PA[patch]
  M --> D[delete]
  M --> H[head]
  M --> F[fetch]
  G & P & PU & PA & D & H & F --> NET[HTTP socket]
  NET --> API[restful-booker]
One fixture, eight verbs, one shared HTTP stack

02Page-attached vs newContextrequest.newContext

The built-in request fixture is "page-attached" - it shares cookies with the browser context that the same test uses. That's useful when a test signs into the UI and then calls an API as the same user.

When you want full control - custom headers, isolated cookies, a different baseURL - you call request.newContext() and dispose of it at the end.

tests/two-contexts.api.spec.ts
import { test, expect, request } from '@playwright/test';

test('newContext for isolated headers', async () => {
  const ctx = await request.newContext({
    baseURL: 'https://restful-booker.herokuapp.com',
    extraHTTPHeaders: { 'X-Trace-Id': 'demo-123' },
    timeout: 15_000,
  });

  const ping = await ctx.get('/ping');
  expect(ping.status()).toBe(201);

  await ctx.dispose();
});
Concernrequest (fixture)request.newContext()
Auto disposedyes - per testno - call ctx.dispose()
Shares browser cookiesyesno
baseURL overrideconfig onlyper call
Use it whenUI + API in same testpure API specs, custom auth
Page-attached request test({ page, request }) shared cookies + storage "sign in once, hit API as the same user" request.newContext() manual lifecycle ctx.dispose() required "full isolation, custom auth"
The two ways to get a request context - pick the right one

03baseURL setupplaywright.config.ts

Setting baseURL once in the config lets you use short paths everywhere. Playwright resolves a path against baseURL using standard URL semantics.

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: {
    baseURL: process.env.BASE_URL ?? 'https://restful-booker.herokuapp.com',
    extraHTTPHeaders: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    },
    ignoreHTTPSErrors: false,
  },
  retries: 2,
  reporter: [['list'], ['html', { open: 'never' }]],
});
flowchart TB
  C[playwright.config.ts] --> B[baseURL = restful-booker.herokuapp.com]
  T[test calls request.get '/booking/1'] --> R{relative or absolute?}
  R -->|relative| J[join with baseURL]
  R -->|absolute| A[use as-is]
  J --> URL[final: https://restful-booker.herokuapp.com/booking/1]
  A --> URL
baseURL resolution - the same algorithm as the browser uses for HTML links
Env override. Use BASE_URL=https://staging.example.com npx playwright test to point the same specs at staging. Never hardcode environments in spec files.

04The endpointsrestful-booker

restful-booker is a public sandbox by Mark Winteringham. It mimics a hotel booking API with eight endpoints. Documentation lives at restful-booker.herokuapp.com/apidoc.

restful-booker endpoints GET /ping heartbeat - 201 POST /auth issue token - 200 GET /booking list of booking ids GET /booking/{id} one booking by id POST /booking create new booking PUT /booking/{id} full replace - token required PATCH /booking/{id} partial update - token required DELETE /booking/{id} remove - 201 - token required verb path behaviour
The eight verbs you will use throughout this lecture
sequenceDiagram
  participant C as client (request fixture)
  participant S as restful-booker

  C->>S: GET /ping
  S-->>C: 201 Created

  C->>S: POST /auth { username:'admin', password:'password123' }
  S-->>C: 200 { token: 'abc' }

  C->>S: POST /booking { ... }
  S-->>C: 200 { bookingid, booking }

  C->>S: GET /booking/{id}
  S-->>C: 200 { firstname, lastname, ... }

  C->>S: PUT /booking/{id} Authorization: Bearer or Cookie: token=
  S-->>C: 200 { ... }

  C->>S: DELETE /booking/{id} Cookie: token=
  S-->>C: 201 Created

  C->>S: GET /booking/{id}
  S-->>C: 404 Not Found
The full lifecycle of a single booking

05Auth - POST /auth and Bearer attachtoken

restful-booker uses a custom token scheme. Send the literal credentials admin / password123 as JSON to POST /auth. The server returns { token: 'abc123' }. From then on, mutating endpoints (PUT, PATCH, DELETE) require either a Cookie: token=abc123 header or Authorization: Basic ... with the admin password.

tests/fixtures/auth.ts
import { test as base, expect, APIRequestContext } from '@playwright/test';

export const test = base.extend<{ authed: APIRequestContext; token: string }>({
  token: async ({ request }, use) => {
    const res = await request.post('/auth', {
      data: { username: 'admin', password: 'password123' },
    });
    expect(res.ok()).toBeTruthy();
    const { token } = await res.json();
    expect(token).toMatch(/^[a-z0-9]+$/);
    await use(token);
  },
  authed: async ({ token, playwright }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: 'https://restful-booker.herokuapp.com',
      extraHTTPHeaders: {
        'Cookie': `token=${token}`,
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    });
    await use(ctx);
    await ctx.dispose();
  },
});

export { expect };

Now every test that imports { test } from this file gets an authed request context, ready to mutate bookings without a manual auth call.

sequenceDiagram
  participant SPEC as spec.ts
  participant FX as auth fixture
  participant API as restful-booker

  SPEC->>FX: needs { authed }
  FX->>API: POST /auth { admin, password123 }
  API-->>FX: 200 { token }
  FX->>FX: build newContext with Cookie header
  FX-->>SPEC: authed (APIRequestContext)
  SPEC->>API: PUT /booking/42 (authed)
  API-->>SPEC: 200 { booking }
  SPEC->>FX: test ends -> dispose
  FX-->>API: socket closed
The auth fixture runs once per test, exposes a ready-to-use context
Test code authed.put('/booking/42', { data: { ... } }) no headers in test! fixture adds Outgoing HTTP PUT /booking/42 HTTP/1.1 Cookie: token=abc123 Content-Type: application/json Accept: application/json headers injected once
The fixture is the only place the cookie lives - tests stay clean

06POST /booking - the full payloadcreate

Creating a booking takes a JSON object with seven fields. Two of them are date strings wrapped in a nested bookingdates object. The server returns the new id and echoes the booking back.

tests/booking-create.api.spec.ts
import { test, expect } from './fixtures/auth';

test('create a booking', async ({ request }) => {
  const res = await request.post('/booking', {
    data: {
      firstname: 'Pramod',
      lastname: 'Dutta',
      totalprice: 219,
      depositpaid: true,
      bookingdates: {
        checkin: '2026-03-01',
        checkout: '2026-03-05',
      },
      additionalneeds: 'Late check-in',
    },
  });

  expect(res.status()).toBe(200);
  const body = await res.json();
  expect(body).toHaveProperty('bookingid');
  expect(body.booking.firstname).toBe('Pramod');
  expect(body.booking.totalprice).toBe(219);
});

Notice POST /booking does not need a token. Anyone can create a booking. The token only matters for mutating an existing booking. This is intentional in the sandbox and is a useful talking point about API design.

07GET /booking/{id} and assertionsread

Reading a booking is the smallest meaningful test - GET, status 200, body has the right shape. This is also where you typically run a schema match (lecture 2 covers AJV).

tests/booking-read.api.spec.ts
import { test, expect } from './fixtures/auth';

test('read a booking by id', async ({ request }) => {
  // list ids first
  const list = await request.get('/booking');
  expect(list.ok()).toBeTruthy();
  const ids = (await list.json()) as { bookingid: number }[];
  expect(ids.length).toBeGreaterThan(0);

  const id = ids[0].bookingid;
  const one = await request.get(`/booking/${id}`);
  expect(one.status()).toBe(200);

  const body = await one.json();
  expect(body).toMatchObject({
    firstname: expect.any(String),
    lastname: expect.any(String),
    totalprice: expect.any(Number),
    depositpaid: expect.any(Boolean),
    bookingdates: {
      checkin: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
      checkout: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
    },
  });
});
toMatchObject vs full equality. Use toMatchObject for partial assertions - it tolerates new optional fields the API adds later. Use deep equal (toEqual) only on contract tests you own end-to-end.

08PUT vs PATCH semanticsupdate

PUT replaces the resource. PATCH updates fields you send. Both work on /booking/{id} in restful-booker, but the contract is different.

OperationBodySemanticsIdempotent?
PUT /booking/42full objectreplace all fieldsyes
PATCH /booking/42partialmerge into existingpractically yes
tests/booking-update.api.spec.ts
import { test, expect } from './fixtures/auth';

test('PUT replaces all fields', async ({ authed, request }) => {
  const create = await request.post('/booking', { data: minimalBooking() });
  const { bookingid } = await create.json();

  const put = await authed.put(`/booking/${bookingid}`, {
    data: { ...minimalBooking(), firstname: 'Renamed' },
  });
  expect(put.status()).toBe(200);
  expect((await put.json()).firstname).toBe('Renamed');
});

test('PATCH updates only sent fields', async ({ authed, request }) => {
  const create = await request.post('/booking', { data: minimalBooking() });
  const { bookingid, booking } = await create.json();

  const patch = await authed.patch(`/booking/${bookingid}`, {
    data: { firstname: 'Patched' },
  });
  expect(patch.status()).toBe(200);

  const body = await patch.json();
  expect(body.firstname).toBe('Patched');
  expect(body.lastname).toBe(booking.lastname);
});

function minimalBooking() {
  return {
    firstname: 'Q', lastname: 'A',
    totalprice: 100, depositpaid: true,
    bookingdates: { checkin: '2026-03-01', checkout: '2026-03-02' },
    additionalneeds: 'none',
  };
}

09DELETE then GET expects 404destroy + verify

Two requests in sequence prove the resource is gone. This is the canonical pattern for any delete test - never assume; assert by re-reading.

tests/booking-delete.api.spec.ts
import { test, expect } from './fixtures/auth';

test('delete then 404', async ({ authed, request }) => {
  const create = await request.post('/booking', { data: minimalBooking() });
  const { bookingid } = await create.json();

  const del = await authed.delete(`/booking/${bookingid}`);
  expect(del.status()).toBe(201);  // quirky 201 from this API

  const read = await request.get(`/booking/${bookingid}`);
  expect(read.status()).toBe(404);
});
stateDiagram-v2
  [*] --> Created : POST /booking
  Created --> Updated : PUT or PATCH /booking/id
  Updated --> Updated : PUT or PATCH again
  Created --> Deleted : DELETE /booking/id
  Updated --> Deleted : DELETE /booking/id
  Deleted --> [*] : GET returns 404
The booking resource state machine

10storageState - reuse the token across testsauth setup

POSTing to /auth once per test wastes time when you have 30 tests. Playwright's globalSetup + storageState pattern lets you authenticate once and feed the saved state to every spec.

global-setup.ts
import { request } from '@playwright/test';
import fs from 'node:fs';

export default async function globalSetup() {
  const ctx = await request.newContext({ baseURL: 'https://restful-booker.herokuapp.com' });
  const res = await ctx.post('/auth', {
    data: { username: 'admin', password: 'password123' },
  });
  const { token } = await res.json();
  fs.writeFileSync('.auth/state.json', JSON.stringify({
    cookies: [{ name: 'token', value: token, domain: 'restful-booker.herokuapp.com', path: '/' }],
    origins: [],
  }, null, 2));
  await ctx.dispose();
}
playwright.config.ts (excerpt)
export default defineConfig({
  globalSetup: './global-setup.ts',
  use: {
    baseURL: 'https://restful-booker.herokuapp.com',
    storageState: '.auth/state.json',
  },
});
flowchart LR
  GS[global-setup.ts] --> A[POST /auth]
  A --> J[.auth/state.json]
  J --> T1[test 1]
  J --> T2[test 2]
  J --> T3[test N]
  T1 --> API[restful-booker]
  T2 --> API
  T3 --> API
One auth call, N tests reuse the saved cookie
Token TTL. restful-booker tokens are short-lived. For long suites, regenerate state inside globalSetup on every CI run. Don't cache it in your repo.

11Retry and backoffresilience

Public sandboxes occasionally hiccup. Wrap fragile calls in a tiny retry helper rather than relying on Playwright's outer retries (which retries the whole test).

lib/retry.ts
export async function retry<T>(
  fn: () => Promise<T>,
  opts: { tries?: number; baseMs?: number } = {}
): Promise<T> {
  const tries = opts.tries ?? 3;
  const base = opts.baseMs ?? 300;
  let err: unknown;
  for (let i = 0; i < tries; i++) {
    try { return await fn(); } catch (e) { err = e; }
    const wait = base * Math.pow(2, i);
    await new Promise(r => setTimeout(r, wait));
  }
  throw err;
}
tests/booking-retry.api.spec.ts
import { test, expect } from './fixtures/auth';
import { retry } from '../lib/retry';

test('create with backoff', async ({ request }) => {
  const body = await retry(async () => {
    const res = await request.post('/booking', { data: minimalBooking() });
    if (!res.ok()) throw new Error(`status ${res.status()}`);
    return res.json();
  }, { tries: 4, baseMs: 400 });
  expect(body).toHaveProperty('bookingid');
});
flowchart TB
  S[start] --> T[try the call]
  T -->|ok| OK[return]
  T -->|fail| C{tries left?}
  C -->|no| F[throw]
  C -->|yes| W[wait base * 2^i]
  W --> T
Exponential backoff - 300, 600, 1200, 2400 ms

12The full E2E chaincreate -> read -> update -> delete -> 404

The final spec wires all the verbs together. One booking, five round trips, one passing test.

tests/booking-e2e.api.spec.ts
import { test, expect } from './fixtures/auth';

test('full CRUD chain on a single booking', async ({ authed, request }) => {
  // CREATE
  const create = await request.post('/booking', {
    data: {
      firstname: 'Pramod', lastname: 'Dutta',
      totalprice: 219, depositpaid: true,
      bookingdates: { checkin: '2026-03-01', checkout: '2026-03-05' },
      additionalneeds: 'Late check-in',
    },
  });
  expect(create.status()).toBe(200);
  const { bookingid } = await create.json();

  // READ
  const read = await request.get(`/booking/${bookingid}`);
  expect(read.status()).toBe(200);

  // UPDATE
  const put = await authed.put(`/booking/${bookingid}`, {
    data: {
      firstname: 'Pramod', lastname: 'D.',
      totalprice: 259, depositpaid: true,
      bookingdates: { checkin: '2026-03-01', checkout: '2026-03-06' },
      additionalneeds: 'Late check-in + breakfast',
    },
  });
  expect(put.status()).toBe(200);
  expect((await put.json()).totalprice).toBe(259);

  // DELETE
  const del = await authed.delete(`/booking/${bookingid}`);
  expect(del.status()).toBe(201);

  // VERIFY GONE
  const ghost = await request.get(`/booking/${bookingid}`);
  expect(ghost.status()).toBe(404);
});
sequenceDiagram
  participant T as test
  participant R as request
  participant A as authed
  participant API as restful-booker

  T->>R: POST /booking
  R->>API: ...
  API-->>R: 200 { bookingid }
  R-->>T: bookingid

  T->>R: GET /booking/id
  R->>API: ...
  API-->>R: 200
  R-->>T: body

  T->>A: PUT /booking/id
  A->>API: + Cookie token
  API-->>A: 200
  A-->>T: updated

  T->>A: DELETE /booking/id
  A->>API: + Cookie token
  API-->>A: 201

  T->>R: GET /booking/id
  R->>API: ...
  API-->>R: 404
  R-->>T: gone
The complete chain - one test, five trips, full coverage