Playwright API Assertions and JSON Schema Validation (Zod, Ajv)

Playwright API Assertions and JSON Schema Validation (Zod, Ajv)

A test is only as good as its assertions. “Status is 200” catches outages; it misses wrong totals, missing fields, leaked data and silent type changes. In this lesson you’ll learn every Playwright assertion that matters for API testing, from toBeOK() and partial matching to soft assertions and polling, then lock down whole responses with JSON Schema validation using Zod and Ajv, including a custom toMatchZodSchema matcher. Every example ran green against the live practice API.

Quick answerKey Playwright API assertions: await expect(res).toBeOK() (2xx, prints the body on failure), expect(res.status()).toBe(201), toMatchObject() for partial body checks, toEqual() with expect.any() / expect.stringContaining() for exact shapes with dynamic values, toContainEqual() for arrays, toBeCloseTo() for money, expect.soft() to report several failures at once and expect.poll() for eventual consistency. For structure, validate responses against a schema with Zod (TypeScript types + runtime checks) or Ajv (standard JSON Schema/OpenAPI).
Advertisement

Layers of an API Assertion

LayerExampleCatches
StatustoBe(201), toBeOK()Outages, wrong codes
HeadersContent-Type, Location, ETag, Cache-ControlCaching/security/contract issues
Valuesname === 'Asha Verma', totalsBusiness logic bugs
Structure (schema)Every field present, typed, no extrasRenamed/removed fields, type changes, data leaks
Side effectsGET after POST, 404 after DELETEWrites that weren’t saved
Non-functionalResponse timePerformance regressions

Good suites combine them: a schema check for structure plus targeted value checks for the business rules under test.

The Assertions in Action

tests/assertions.spec.ts (uses the fixtures and schemas from the framework):

import { test, expect } from '../src/fixtures';
import { UserSchema, UserListSchema, ErrorSchema, validateJsonSchema } from '../src/schemas';

test.describe('Assertions and schema validation', () => {
  test('status, headers and timing', async ({ request }) => {
    const start = Date.now();
    const res = await request.get('users/1');
    const elapsed = Date.now() - start;

    await expect(res).toBeOK();                                    // 200-299, prints the body if it fails
    expect(res.headers()).toMatchObject({
      'content-type': expect.stringContaining('application/json'),
      'cache-control': expect.stringContaining('no-store'),
    });
    expect(elapsed).toBeLessThan(3_000);
  });

  test('partial and type-based matching', async ({ usersApi }) => {
    const { data } = await (await usersApi.get(1)).json();
    expect(data).toMatchObject({ id: 1, role: 'admin' });          // only these keys checked
    expect(data).toEqual({                                         // every key checked
      id: 1, name: 'Asha Verma', username: 'asha', email: 'asha@example.com', role: 'admin',
      city: 'Pune', age: 34, active: true, created_at: expect.any(String),
    });
    expect(data.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
  });

  test('arrays: every item, sorting and containment', async ({ usersApi }) => {
    const { data } = await (await usersApi.list({ city: 'pune', per_page: 50 })).json();
    expect(data.length).toBeGreaterThan(0);
    for (const u of data) expect(u.city.toLowerCase()).toBe('pune');

    const sorted = (await (await usersApi.list({ sort: 'age', order: 'desc', per_page: 50 })).json()).data;
    const ages = sorted.map((u: { age: number }) => u.age);
    expect(ages).toEqual([...ages].sort((a: number, b: number) => b - a));
    expect(sorted).toContainEqual(expect.objectContaining({ name: 'Manish Gupta' }));
  });

  test('soft assertions report every failure in one run', async ({ request }) => {
    const { data } = await (await request.get('orders/5001')).json();
    expect.soft(data.status).toBe('delivered');
    expect.soft(data.summary.item_count).toBe(data.items.length);
    expect.soft(data.summary.subtotal).toBe(1947);
    expect.soft(data.summary.tax).toBeCloseTo(1947 * 0.18, 2);
    expect.soft(data.summary.total).toBeCloseTo(2297.46, 2);
  });

  test('Zod schema: single user and list', async ({ usersApi }) => {
    const one = await (await usersApi.get(1)).json();
    expect(() => UserSchema.parse(one.data)).not.toThrow();

    const list = await (await usersApi.list({ per_page: 50 })).json();
    const parsed = UserListSchema.safeParse(list);
    expect(parsed.success, parsed.success ? '' : parsed.error.message).toBe(true);
  });

  test('Ajv: validate against the API-published JSON Schema', async ({ request, usersApi }) => {
    const schema = await (await request.get('schema/user')).json();
    const { data } = await (await usersApi.list({ per_page: 50 })).json();
    for (const user of data) {
      const { ok, errors } = validateJsonSchema(schema, user);
      expect(ok, `user ${user.id}: ${errors}`).toBe(true);
    }
  });

  test('error responses follow the error contract', async ({ request }) => {
    const res = await request.post('validate/user', { data: { name: 'A', email: 'x', age: '30' } });
    expect(res.status()).toBe(422);
    const body = ErrorSchema.parse(await res.json());
    expect(body.error.details?.map(d => d.field).sort()).toEqual(['age', 'email', 'name']);
  });

  test('expect.poll waits for an eventual state (object expiry)', async ({ objectsApi }) => {
    test.setTimeout(40_000);
    const created = await objectsApi.createOrThrow({ name: 'Short lived', ttl: 10 });
    await expect.poll(async () => (await objectsApi.get(created.id)).status(), {
      intervals: [2_000], timeout: 25_000,
    }).toBe(404);
  });
});
Advertisement

Choosing the Right Matcher

NeedMatcherNote
Success with a helpful failure messageawait expect(res).toBeOK()Async; prints status and body when it fails
Exact statusexpect(res.status()).toBe(422)Use for every non-200 expectation
Some fieldstoMatchObject({...})Extra fields allowed; nested objects partially matched
Whole objecttoEqual({...})Combine with expect.any(String) for ids/timestamps
Array contains an itemtoContainEqual(expect.objectContaining({...}))Order-independent
Numbers with decimalstoBeCloseTo(2297.46, 2)Avoids floating point surprises
PatternstoMatch(/regex/)Dates, ids, emails
Several checks, see all failuresexpect.soft(...)Test still fails at the end
Eventually trueexpect.poll(fn, { intervals, timeout })Async jobs, expiry, eventual consistency
Custom failure messageexpect(value, 'user 7 role').toBe('admin')Second argument appears in the report
toMatchObject vs toEqual toMatchObject is forgiving: perfect when the API may add fields later. toEqual is strict: use it when an unexpected new field should fail the test (for example, responses that must never leak data). Schemas give you the strict check without listing every value.
Advertisement

Schema Validation with Zod

Zod defines a schema in TypeScript that is both a runtime validator and a static type. npm install -D zod, then src/schemas.ts:

import { z } from 'zod';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

// ---- Zod: schema and TypeScript type in one place
export const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  username: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  city: z.string(),
  age: z.number().int().min(18),
  active: z.boolean(),
  created_at: z.string().datetime(),
}).strict();                                   // strict() = no unknown keys (like additionalProperties: false)

export type User = z.infer<typeof UserSchema>;

export const UserListSchema = z.object({
  data: z.array(UserSchema),
  meta: z.object({
    page: z.number(), per_page: z.number(), total: z.number(), total_pages: z.number(),
    sort: z.string(), order: z.enum(['asc', 'desc']),
  }),
  links: z.object({
    self: z.string().url(), first: z.string().url(), last: z.string().url(),
    prev: z.string().url().nullable(), next: z.string().url().nullable(),
  }),
});

export const ErrorSchema = z.object({
  error: z.object({
    status: z.number(),
    code: z.string(),
    message: z.string(),
    details: z.array(z.object({ field: z.string(), message: z.string() })).optional(),
  }),
});

// ---- Ajv: validate against JSON Schema documents (e.g. the API's own /schema/user)
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);

export function validateJsonSchema(schema: object, data: unknown): { ok: boolean; errors: string } {
  const validate = ajv.compile(schema);
  const ok = validate(data) as boolean;
  return { ok, errors: ajv.errorsText(validate.errors) };
}

The User type now gives autocompletion everywhere (user.role is 'admin' | 'editor' | 'viewer'). And the ErrorSchema makes every negative test also verify the error format.

Advertisement

A Custom Matcher: toMatchZodSchema

Turn schema validation into one readable line with a clear failure message, using expect.extend:

import { expect as baseExpect } from '@playwright/test';
import { test } from '../src/fixtures';
import { z } from 'zod';
import { UserSchema } from '../src/schemas';

// A reusable custom matcher: expect(value).toMatchZodSchema(schema)
const expect = baseExpect.extend({
  toMatchZodSchema(received: unknown, schema: z.ZodTypeAny) {
    const result = schema.safeParse(received);
    return {
      pass: result.success,
      name: 'toMatchZodSchema',
      message: () => result.success
        ? 'expected value NOT to match the schema'
        : 'schema mismatch:\n' + result.error.issues.map(i => `  ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n'),
    };
  },
});

test('every user matches the Zod schema (custom matcher)', async ({ usersApi }) => {
  const { data } = (await (await usersApi.list({ per_page: 50 })).json()) as { data: unknown[] };
  for (const user of data) expect(user).toMatchZodSchema(UserSchema);
});

test('the matcher explains what is wrong', async () => {
  const broken = { id: '1', name: 'Asha', extra: true };
  expect(() => expect(broken).toMatchZodSchema(UserSchema)).toThrow(/id: Invalid input|id: Expected number/);
});

Note the as { data: unknown[] } cast: res.json() returns any, and TypeScript doesn’t offer custom matchers on any values, so typing the parsed body (which is good practice anyway) makes toMatchZodSchema available. When a response breaks the contract, the report shows exactly what’s wrong, for example:

schema mismatch:
  id: Invalid input: expected number, received string
  (root): Unrecognized key: "extra"

In a real framework, put the extend call in src/fixtures.ts and export that expect, so every test gets the matcher.

Zod or Ajv?

ZodAjv
Schema written inTypeScript codeJSON Schema (JSON/YAML)
TypeScript typesAutomatic (z.infer)Separate (or generated)
Reuse the API team’s OpenAPI/JSON SchemaNeeds conversionDirectly
Best whenTesters own the expected shapesA published contract exists (OpenAPI, /schema/user)

Many frameworks use both: Ajv against the official spec for contract checks, Zod for typed helpers and test-side models.

Advertisement

Soft Assertions and Polling

expect.soft

Checking an order’s five calculated fields? With normal assertions the first failure stops the test and hides the others. expect.soft records each failure and fails the test at the end, so one run shows every broken calculation. Don’t use soft assertions for prerequisites: if the status is 500, there’s no body to check.

expect.poll

Asynchronous systems (report generation, payment status, search indexing, cache expiry) become correct eventually. Never use fixed sleeps; poll with a timeout. The practice API’s objects expire after their TTL, so the example polls until GET returns 404. Similar retry logic is available as expect(async () => {...}).toPass() when you need a block of assertions to eventually pass.

Assertion Anti-Patterns

  • expect(res.ok()).toBeTruthy() alone: on failure you only learn “expected true”. Prefer toBeOK() or the exact status.
  • Asserting the entire body with hardcoded ids and timestamps: flaky. Use expect.any() and schemas.
  • Checking only the first array item.
  • Comparing money with toBe after calculations.
  • Catching errors inside tests and asserting nothing.
  • waitForTimeout/sleep instead of expect.poll.
Advertisement

Interview Questions: Playwright API Assertions

How do you assert an API response in Playwright?

Check the status with expect(res.status()).toBe() or await expect(res).toBeOK(), headers with res.headers(), and the parsed body from await res.json() using toMatchObject, toEqual, toContainEqual or schema validation.

What is the difference between toMatchObject and toEqual?

toMatchObject checks only the properties you specify and allows extra properties; toEqual requires the whole structure to be equal (ignoring undefined properties). Asymmetric matchers like expect.any(String) help with dynamic values.

What does toBeOK do?

It’s a Playwright API response assertion that passes when the status is 200 to 299 and, when it fails, prints the response status and body, making failures easy to diagnose.

What are soft assertions?

Assertions made with expect.soft that don’t stop the test on failure; all failures are reported at the end and the test is marked failed. Useful for checking many independent fields in one response.

How do you validate a JSON schema in Playwright?

Use a library: Ajv to validate against JSON Schema or OpenAPI definitions, or Zod schemas with parse or safeParse. Wrap it in a helper or a custom matcher created with expect.extend.

Advertisement
Why use Zod in a TypeScript API framework?

A Zod schema is both a runtime validator and a source of TypeScript types via z.infer, so responses are validated and strongly typed from one definition.

How do you test eventually consistent behaviour?

Use expect.poll with a function that fetches the current state, plus intervals and a timeout, or expect(…).toPass() for a block of assertions, instead of fixed sleeps.

How do you create a custom matcher in Playwright?

Call expect.extend with matcher functions that return pass, message and name, then use the returned expect in tests, for example expect(user).toMatchZodSchema(UserSchema).

How do you compare decimal values safely?

Use toBeCloseTo(expected, numDigits), or compare integer minor units such as paise.

How do you add context to a failing assertion?

Pass a custom message as the second argument to expect, for example expect(ok, `user ${id}: ${errors}`).toBe(true), so the report explains which item failed and why.

Advertisement
View the full API testing syllabus