Practice API Testing Auth + Schema
Lecture 2
Lecture 2 . Auth flows + JSON Schema

Auth flows + JSON Schema

Real APIs require credentials. Real responses must match a contract. This lecture covers Basic Auth, OAuth2 client_credentials against Spotify, dynamic token refresh on 401, and AJV-based schema validation with a custom expect matcher.

01The auth landscapeBasic, Bearer, OAuth2

An API call without credentials gets you anonymous access at best, a 401 at worst. Real services use one of three patterns: HTTP Basic Auth, opaque Bearer tokens, or OAuth2 access tokens. Playwright's request fixture supports all three equally well.

SchemeHeaderWhere credentials liveRenewable?
BasicAuthorization: Basic base64(user:pass)your .env or a secret managerno, you keep using the same creds
Bearer (opaque)Authorization: Bearer xyzissued by an auth endpointvia a refresh endpoint or a second login
OAuth2 client_credentialsAuthorization: Bearer xyzclient_id + client_secret -> token endpointyes, with TTL in the response
OAuth2 authorization_codeAuthorization: Bearer xyzuser browser flow + redirectrefresh_token
sequenceDiagram
  participant T as test
  participant API as protected API

  Note over T,API: Basic Auth
  T->>API: GET /me + Authorization: Basic base64(user:pass)
  API-->>T: 200 OR 401

  Note over T,API: OAuth2 client_credentials
  T->>API: POST /token + client_id + client_secret
  API-->>T: 200 { access_token, expires_in }
  T->>API: GET /me + Authorization: Bearer access_token
  API-->>T: 200
Basic Auth is one request, OAuth2 is two

02Basic Auth in PlaywrighthttpCredentials

Basic Auth is the simplest scheme - the username and password go on every request, base64-encoded. Playwright handles the encoding for you when you set httpCredentials on the context.

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

test('basic auth via httpCredentials', async () => {
  const ctx = await request.newContext({
    baseURL: 'https://httpbin.org',
    httpCredentials: { username: 'demo', password: 'pass' },
  });

  const res = await ctx.get('/basic-auth/demo/pass');
  expect(res.status()).toBe(200);
  expect((await res.json()).authenticated).toBe(true);

  const wrong = await ctx.get('/basic-auth/demo/different');
  expect(wrong.status()).toBe(401);

  await ctx.dispose();
});
Basic Auth over HTTPS only. The header is base64, NOT encrypted. Anyone watching plain HTTP can decode demo:pass in milliseconds. Use TLS or pick OAuth2.
HTTP request - Basic Auth header anatomy GET /me HTTP/1.1 Host: api.example.com Authorization: Basic ZGVtbzpwYXNz base64(demo:pass) - NOT encrypted
The header is one line - everything sits in the value

03OAuth2 client_credentials against Spotifytwo-step

Spotify's public Web API ships with a free client credentials grant. You register a small app on the developer dashboard, get a client_id and a client_secret, exchange them for an access token, then use the token to read public data such as new releases.

Step 1 - get a token

lib/spotify.ts
import { request } from '@playwright/test';

export async function getSpotifyToken(id: string, secret: string) {
  const ctx = await request.newContext();
  const body = new URLSearchParams({ grant_type: 'client_credentials' });
  const creds = Buffer.from(`${id}:${secret}`).toString('base64');

  const res = await ctx.post('https://accounts.spotify.com/api/token', {
    headers: {
      'Authorization': `Basic ${creds}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    data: body.toString(),
  });
  if (!res.ok()) throw new Error(`token request failed: ${res.status()}`);
  const json = await res.json();
  await ctx.dispose();
  return json as { access_token: string; expires_in: number; token_type: 'Bearer' };
}

Step 2 - use the token

tests/spotify.api.spec.ts
import { test, expect, request } from '@playwright/test';
import { getSpotifyToken } from '../lib/spotify';

test('new releases endpoint returns 20 albums', async () => {
  const { access_token } = await getSpotifyToken(
    process.env.SPOTIFY_CLIENT_ID!,
    process.env.SPOTIFY_CLIENT_SECRET!,
  );

  const ctx = await request.newContext({
    baseURL: 'https://api.spotify.com',
    extraHTTPHeaders: { 'Authorization': `Bearer ${access_token}` },
  });

  const res = await ctx.get('/v1/browse/new-releases?limit=20');
  expect(res.status()).toBe(200);
  const body = await res.json();
  expect(body.albums.items).toHaveLength(20);
  await ctx.dispose();
});
sequenceDiagram
  participant T as test
  participant A as accounts.spotify.com
  participant API as api.spotify.com

  T->>A: POST /api/token (grant_type=client_credentials)
  Note over T,A: Authorization: Basic base64(id:secret)
  A-->>T: 200 { access_token, expires_in: 3600 }

  T->>API: GET /v1/browse/new-releases?limit=20
  Note over T,API: Authorization: Bearer access_token
  API-->>T: 200 { albums: { items: [...] } }
OAuth2 client_credentials - one POST for a token, then Bearer on every call

04Dynamic token refresh on 401resilience

Tokens expire. A long-running suite that grabbed a token at globalSetup may see it expire halfway through. The classic recovery is: on 401, fetch a fresh token and retry the request once.

lib/auto-refresh.ts
import { APIRequestContext, request } from '@playwright/test';
import { getSpotifyToken } from './spotify';

let currentToken: string | null = null;

async function freshContext(): Promise<APIRequestContext> {
  const { access_token } = await getSpotifyToken(
    process.env.SPOTIFY_CLIENT_ID!,
    process.env.SPOTIFY_CLIENT_SECRET!,
  );
  currentToken = access_token;
  return request.newContext({
    baseURL: 'https://api.spotify.com',
    extraHTTPHeaders: { 'Authorization': `Bearer ${access_token}` },
  });
}

export async function getWithRefresh(path: string) {
  let ctx = await freshContext();
  let res = await ctx.get(path);
  if (res.status() === 401) {
    await ctx.dispose();
    ctx = await freshContext();
    res = await ctx.get(path);
  }
  const body = await res.json();
  await ctx.dispose();
  return { status: res.status(), body };
}
flowchart TB
  S[start] --> C[get token if missing]
  C --> R[GET /v1/browse/new-releases]
  R -->|200| OK[return body]
  R -->|401| REF[fetch fresh token]
  REF --> R2[retry GET]
  R2 -->|200| OK
  R2 -->|401| FAIL[fail loudly]
Refresh once. If still 401, the credentials themselves are wrong
Why retry only once? If the credentials are wrong, retrying forever just hammers the auth server and locks the client. A single retry covers the "expired between request and now" race; everything else is a config bug.

05AJV - install and instantiateajv + ajv-formats

AJV is the fastest JSON Schema validator in the Node.js ecosystem. It compiles a schema once into a JavaScript function, then validates instances against it in microseconds.

install
npm install ajv ajv-formats --save-dev
lib/ajv.ts
import Ajv, { ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);  // date, date-time, email, uri, uuid, etc.

export { ajv };
export type { ErrorObject };
JSON Schema booking.schema.json compile validator fn (data) => boolean call true / false + errors[] cache per schema compile once, validate many
AJV compiles each schema once into a tight JS function

06Custom matcher - expect.extendtoMatchSchema

Calling ajv.validate(schema, body) directly works but yields ugly error messages. A custom expect matcher integrates with Playwright's reporter and gives you a clean failure with the full validation path.

lib/matcher.ts
import { expect } from '@playwright/test';
import { ajv } from './ajv';

expect.extend({
  toMatchSchema(received: unknown, schema: object) {
    const validate = ajv.compile(schema);
    const ok = validate(received);
    if (ok) return { pass: true, message: () => 'matched' };

    const details = (validate.errors ?? []).map(e =>
      `${e.instancePath || '(root)'} ${e.message}`
    ).join('\n');

    return {
      pass: false,
      message: () => `Schema mismatch:\n${details}`,
    };
  },
});

declare module '@playwright/test' {
  interface Matchers<R> {
    toMatchSchema(schema: object): R;
  }
}

Pull the matcher file into your global tests/setup.ts:

tests/setup.ts
import '../lib/matcher';
playwright.config.ts (excerpt)
export default defineConfig({
  globalSetup: './tests/setup.ts',
  // ...rest of config
});

07Authoring a JSON Schema (draft-07)$schema, type, required, properties

JSON Schema draft-07 is the practical default. Newer drafts (2019-09, 2020-12) add features but draft-07 has the widest ecosystem support.

Minimum viable schema

schemas/email.schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "email": { "type": "string", "format": "email" }
  },
  "required": ["email"],
  "additionalProperties": false
}

Constraint families

  • Strings - minLength, maxLength, pattern (regex), format (date, date-time, email, uri, uuid via ajv-formats)
  • Numbers - minimum, maximum, exclusiveMaximum, multipleOf
  • Arrays - items, minItems, maxItems, uniqueItems
  • Objects - properties, required, additionalProperties, patternProperties
  • Combinators - allOf, anyOf, oneOf, not
flowchart LR
  REQ[response.json] --> AJV[ajv.compile schema]
  AJV --> VAL{validate}
  VAL -->|ok| PASS[expect passes]
  VAL -->|errors| FAIL[expect fails with path + message]
  SCH[schemas/*.schema.json] --> AJV
The AJV pipeline - same shape whether you have 1 schema or 50

08The booking schemarestful-booker

The schema for a single booking returned by GET /booking/{id}:

schemas/booking.schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "booking",
  "type": "object",
  "properties": {
    "firstname": { "type": "string", "minLength": 1 },
    "lastname":  { "type": "string", "minLength": 1 },
    "totalprice": { "type": "number", "minimum": 0 },
    "depositpaid": { "type": "boolean" },
    "bookingdates": {
      "type": "object",
      "properties": {
        "checkin":  { "type": "string", "format": "date" },
        "checkout": { "type": "string", "format": "date" }
      },
      "required": ["checkin", "checkout"]
    },
    "additionalneeds": { "type": "string" }
  },
  "required": ["firstname", "lastname", "totalprice", "depositpaid", "bookingdates"],
  "additionalProperties": false
}

Use it in a spec

tests/booking-schema.api.spec.ts
import { test, expect } from '@playwright/test';
import bookingSchema from '../schemas/booking.schema.json';

test('GET /booking/1 matches schema', async ({ request }) => {
  const res = await request.get('https://restful-booker.herokuapp.com/booking/1');
  expect(res.status()).toBe(200);
  const body = await res.json();
  expect(body).toMatchSchema(bookingSchema);
});

tsconfig.json must allow importing JSON: set "resolveJsonModule": true under compilerOptions.

09Drift detectioncontract vs prod

Schema drift means a field changed in production without the schema being updated. Catch it by running the schema test against production daily and surfacing failures.

tests/drift.api.spec.ts
import { test, expect } from '@playwright/test';
import bookingSchema from '../schemas/booking.schema.json';

test.describe('contract drift', () => {
  test('/booking/{first} matches checked-in schema', async ({ request }) => {
    const list = await request.get('/booking');
    const ids = (await list.json()) as { bookingid: number }[];

    const res = await request.get(`/booking/${ids[0].bookingid}`);
    const body = await res.json();
    expect(body).toMatchSchema(bookingSchema);
  });
});
repo schema "firstname": string "lastname": string "totalprice": number "depositpaid": boolean additionalProperties: false your team's contract production response "firstname": string "lastname": string "totalprice": number "depositpaid": boolean + "vipFlag": boolean <-- NEW drift detected
One extra field in prod, no matching schema entry - AJV blocks the merge
Tighten or loosen. additionalProperties: false is strict - useful for contract drift. additionalProperties: true is loose - good for forward compatibility on consumer-side tests. Pick one per direction.