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.
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).Layers of an API Assertion
| Layer | Example | Catches |
|---|---|---|
| Status | toBe(201), toBeOK() | Outages, wrong codes |
| Headers | Content-Type, Location, ETag, Cache-Control | Caching/security/contract issues |
| Values | name === 'Asha Verma', totals | Business logic bugs |
| Structure (schema) | Every field present, typed, no extras | Renamed/removed fields, type changes, data leaks |
| Side effects | GET after POST, 404 after DELETE | Writes that weren’t saved |
| Non-functional | Response time | Performance 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);
});
});
Choosing the Right Matcher
| Need | Matcher | Note |
|---|---|---|
| Success with a helpful failure message | await expect(res).toBeOK() | Async; prints status and body when it fails |
| Exact status | expect(res.status()).toBe(422) | Use for every non-200 expectation |
| Some fields | toMatchObject({...}) | Extra fields allowed; nested objects partially matched |
| Whole object | toEqual({...}) | Combine with expect.any(String) for ids/timestamps |
| Array contains an item | toContainEqual(expect.objectContaining({...})) | Order-independent |
| Numbers with decimals | toBeCloseTo(2297.46, 2) | Avoids floating point surprises |
| Patterns | toMatch(/regex/) | Dates, ids, emails |
| Several checks, see all failures | expect.soft(...) | Test still fails at the end |
| Eventually true | expect.poll(fn, { intervals, timeout }) | Async jobs, expiry, eventual consistency |
| Custom failure message | expect(value, 'user 7 role').toBe('admin') | Second argument appears in the report |
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.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.
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?
| Zod | Ajv | |
|---|---|---|
| Schema written in | TypeScript code | JSON Schema (JSON/YAML) |
| TypeScript types | Automatic (z.infer) | Separate (or generated) |
| Reuse the API team’s OpenAPI/JSON Schema | Needs conversion | Directly |
| Best when | Testers own the expected shapes | A 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.
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”. PrefertoBeOK()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
toBeafter calculations. - Catching errors inside tests and asserting nothing.
waitForTimeout/sleepinstead ofexpect.poll.
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.
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.