Playwright Contract Testing with TypeScript
Playwright contract testing with TypeScript is the safety net I add when a UI suite keeps breaking because the backend response shape changed quietly. In this Day 46 tutorial, you will build contract checks around Playwright API tests, validate JSON with Zod and AJV, connect those checks to UI flows, and avoid turning contract testing into another noisy pipeline.
Table of Contents
- Why Playwright contract testing with TypeScript matters
- Contract tests vs API tests vs E2E tests
- Project setup for contract checks
- Runtime contracts with Zod
- JSON Schema contracts with AJV
- Bridge API contracts into UI tests
- Mocks and negative contracts
- CI reporting and release gates
- Common pitfalls
- Key takeaways
- FAQ
Contents
Why Playwright contract testing with TypeScript matters
Most Playwright tutorials focus on clicking buttons and checking visible text. That is useful, but it misses a painful class of failures: the UI receives a technically valid HTTP 200 response, but the JSON shape no longer matches what the frontend expects. The page may show a blank price, hide a button, display “undefined”, or fail only for one account type.
Contract testing catches that earlier. A contract is an agreement about request fields, response fields, types, required values, status codes, and sometimes business invariants. When I say Playwright contract testing with TypeScript, I mean tests that call an API using Playwright’s request fixture, validate the response against a typed contract, and fail with a useful message when the provider breaks the consumer expectation.
Where this fits in the series
If you followed Day 8 on Playwright API testing, you already know how to call REST endpoints with request.get() and request.post(). If you followed Day 11 on Playwright network mocking, you also know how to control browser traffic. Today we connect both ideas to contracts.
Why TypeScript helps
TypeScript gives you two wins. First, your test helpers become easier to read because response objects have declared shapes. Second, tools like Zod can infer TypeScript types from runtime schemas, so the same contract can validate real JSON and guide test code completion.
For scale context, the npm downloads API reported 208,655,630 last-month downloads for @playwright/test, and the source lives in microsoft/playwright on GitHub.
Contract tests vs API tests vs E2E tests
Teams often use these names loosely. That creates confusion in code reviews and interviews. I keep the difference simple.
API test
An API test checks whether an endpoint behaves correctly for a request. It may assert status code, response body, database side effect, headers, and error handling. Example: create an order, then verify the API returns the new order with status 201.
Contract test
A contract test checks whether the provider response still matches the consumer’s expected shape and rules. Example: the frontend expects order.id as a string, order.total.amount as a number, and order.status as one of CREATED, PAID, or CANCELLED. If the backend changes amount to a string, the contract test fails before the UI silently formats it wrong.
E2E test
An E2E test checks a user workflow through the browser. Example: a customer logs in, adds a product, pays, and sees an order confirmation. It gives high confidence but costs more time and is harder to debug when the failure is buried behind five screens.
A practical split
- Use API tests for endpoint behavior and backend workflow checks.
- Use contract tests for response shape, enum values, nullability, and backwards compatibility.
- Use E2E tests for the few flows where the browser experience is the product risk.
In India SDET interviews, this distinction matters. For ₹25-40 LPA roles, interviewers expect you to explain why not every check belongs in the browser. A candidate who says “I moved 70% of validation into API and contract layers, then kept five critical E2E smoke tests” sounds more senior than a candidate who only says “I wrote Playwright scripts”.
Project setup for contract checks
Start with a normal Playwright TypeScript project. Add Zod for runtime validation and AJV for JSON Schema validation. Zod’s documentation describes it as a TypeScript-first schema validation library. AJV describes itself as Another JSON Schema Validator, and it is common in Node.js projects that already publish OpenAPI or JSON Schema files.
npm init playwright@latest
npm install -D zod ajv ajv-formats
Create a contracts folder. Keep it outside page objects. Page objects model UI behavior. Contracts model data agreements.
tests/
contracts/
order.contract.ts
customer.contract.ts
api/
order-contract.spec.ts
fixtures/
api-client.ts
e2e/
order-ui.spec.ts
playwright.config.ts
Use a contract project
I prefer a separate Playwright project for contract checks. It lets CI run contracts early and fail fast before launching browser-heavy suites.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.APP_URL ?? 'https://qa.example.com',
extraHTTPHeaders: {
'x-test-suite': 'playwright-contracts'
}
},
projects: [
{
name: 'contracts',
testMatch: /.*contract\.spec\.ts/,
use: { browserName: 'chromium' }
},
{
name: 'chromium-e2e',
dependencies: ['contracts'],
testMatch: /.*ui\.spec\.ts/,
use: { browserName: 'chromium' }
}
],
reporter: [['html'], ['json', { outputFile: 'test-results/results.json' }]]
});
Notice the dependency. If contracts fail, the E2E project does not waste time clicking through a broken application. This is not always required, but it is a clean default for a learning project.
Screenshot description
Runtime contracts with Zod
Zod is my first choice when the contract is owned by the test code or frontend team. It is readable, strict enough for most QA use cases, and it gives good error messages.
Define the response contract
// tests/contracts/order.contract.ts
import { z } from 'zod';
export const MoneySchema = z.object({
currency: z.enum(['INR', 'USD', 'EUR']),
amount: z.number().nonnegative()
});
export const OrderSchema = z.object({
id: z.string().min(1),
customerId: z.string().min(1),
status: z.enum(['CREATED', 'PAID', 'CANCELLED']),
total: MoneySchema,
items: z.array(z.object({
sku: z.string().min(1),
quantity: z.number().int().positive(),
price: MoneySchema
})).min(1),
createdAt: z.string().datetime()
}).strict();
export type Order = z.infer<typeof OrderSchema>;
The .strict() choice is intentional. It fails when the provider adds unexpected fields. Some teams prefer allowing additive fields for backwards compatibility. That is a product decision. For internal admin APIs, strict mode can catch accidental payload bloat. For public APIs, you may prefer .passthrough() and only fail on missing or incompatible fields.
Validate with Playwright request
// tests/api/order-contract.spec.ts
import { test, expect } from '@playwright/test';
import { OrderSchema } from '../contracts/order.contract';
test('GET /api/orders/{id} matches the order contract', async ({ request }) => {
const response = await request.get('/api/orders/ord_1001');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
const body = await response.json();
const result = OrderSchema.safeParse(body);
expect(result.success, JSON.stringify(result.error?.format(), null, 2)).toBe(true);
if (result.success) {
expect(result.data.total.amount).toBeGreaterThan(0);
expect(result.data.items.length).toBeGreaterThanOrEqual(1);
}
});
The key is the assertion message. Bad contract tests fail with “expected true to be false”. Good contract tests tell the developer which field broke. The formatted Zod error gives a direct path to the wrong field.
Validate lists without hiding item-level errors
List endpoints fail in a different way. One bad item inside 50 results can hide in a generic array validation error. Add context around index and ID.
test('GET /api/orders returns valid order summaries', async ({ request }) => {
const response = await request.get('/api/orders?limit=20');
expect(response.ok()).toBeTruthy();
const body = await response.json();
const orders = z.array(OrderSchema).parse(body.data);
for (const [index, order] of orders.entries()) {
await test.step(`contract smoke for order ${index}: ${order.id}`, async () => {
expect(order.id).toMatch(/^ord_/);
expect(order.total.currency).toBe('INR');
});
}
});
Use test.step() when the failure needs to show business context in the report. A validation error plus an order ID saves time during triage.
JSON Schema contracts with AJV
Use AJV when your team already publishes OpenAPI specs, when backend engineers maintain JSON Schema files, or when contracts are shared across several languages. The AJV package has huge npm usage according to the npm downloads API, and that usually means fewer surprises in CI images and enterprise pipelines.
Create a JSON Schema
// tests/contracts/order.schema.ts
export const orderJsonSchema = {
type: 'object',
additionalProperties: false,
required: ['id', 'customerId', 'status', 'total', 'items', 'createdAt'],
properties: {
id: { type: 'string', minLength: 1 },
customerId: { type: 'string', minLength: 1 },
status: { enum: ['CREATED', 'PAID', 'CANCELLED'] },
total: { $ref: '#/$defs/money' },
items: {
type: 'array',
minItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: ['sku', 'quantity', 'price'],
properties: {
sku: { type: 'string', minLength: 1 },
quantity: { type: 'integer', minimum: 1 },
price: { $ref: '#/$defs/money' }
}
}
},
createdAt: { type: 'string', format: 'date-time' }
},
$defs: {
money: {
type: 'object',
additionalProperties: false,
required: ['currency', 'amount'],
properties: {
currency: { enum: ['INR', 'USD', 'EUR'] },
amount: { type: 'number', minimum: 0 }
}
}
}
} as const;
Compile once, validate many times
// tests/api/order-ajv-contract.spec.ts
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { orderJsonSchema } from '../contracts/order.schema';
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
const validateOrder = ajv.compile(orderJsonSchema);
test('order response matches JSON Schema contract', async ({ request }) => {
const response = await request.get('/api/orders/ord_1001');
expect(response.ok()).toBeTruthy();
const body = await response.json();
const valid = validateOrder(body);
expect(valid, ajv.errorsText(validateOrder.errors, { separator: '\n' })).toBe(true);
});
AJV is more verbose than Zod, but it fits teams that need schema portability. If your backend repo already generates OpenAPI contracts, do not manually duplicate every schema in the QA repo. Pull the generated schema artifact into CI and validate against it.
Choosing Zod or AJV
- Choose Zod when the QA or frontend team owns the consumer contract.
- Choose AJV when JSON Schema or OpenAPI is the source of truth.
- Choose both only when there is a clear reason. Duplicate contracts drift quickly.
- Do not validate every field on every test. Contract tests should be focused and fast.
Bridge API contracts into UI tests
Contract tests become more powerful when you connect them to UI risk. The goal is not to parse every API response during every browser test. That makes tests slow and noisy. Instead, validate the API shape before a workflow that depends on that shape.
API pre-check before UI flow
import { test, expect } from '@playwright/test';
import { OrderSchema } from '../contracts/order.contract';
test('customer sees total on order details page', async ({ page, request }) => {
const apiResponse = await request.get('/api/orders/ord_1001');
const order = OrderSchema.parse(await apiResponse.json());
await page.goto(`/orders/${order.id}`);
await expect(page.getByRole('heading', { name: `Order ${order.id}` })).toBeVisible();
await expect(page.getByTestId('order-total')).toContainText(String(order.total.amount));
await expect(page.getByTestId('order-currency')).toContainText(order.total.currency);
});
This pattern makes the test failure honest. If the API contract is broken, the test fails before the UI assertion. If the API contract is valid but the UI renders the wrong value, the UI assertion fails. Triage becomes cleaner.
Attach contract evidence
When a contract breaks in CI, developers ask for payload evidence. Attach a sanitized JSON file to the Playwright report. Never attach tokens, personal information, or payment data.
test('contract evidence is attached safely', async ({ request }, testInfo) => {
const response = await request.get('/api/orders/ord_1001');
const body = await response.json();
await testInfo.attach('order-response.json', {
body: JSON.stringify({ ...body, customerEmail: '[redacted]' }, null, 2),
contentType: 'application/json'
});
expect(OrderSchema.safeParse(body).success).toBe(true);
});
Screenshot description
Mocks and negative contracts
Positive contracts prove the happy path. Negative contracts prove the UI handles provider mistakes gracefully. This is where network mocking helps.
Mock a broken provider response
test('UI shows fallback when order total is missing', async ({ page }) => {
await page.route('**/api/orders/ord_1001', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'ord_1001',
customerId: 'cus_1',
status: 'PAID',
items: [],
createdAt: new Date().toISOString()
})
});
});
await page.goto('/orders/ord_1001');
await expect(page.getByText('Order total is unavailable')).toBeVisible();
});
This is not a contract test by itself. It is a UI resilience test using a known contract violation. It helps product teams decide what should happen when a provider misbehaves.
Mock realistic contract versions
If your API has versions, keep mock payloads versioned too.
tests/mocks/contracts/
order.v1.valid.json
order.v1.missing-total.json
order.v2.valid.json
Do not hand-edit the same mock in 20 tests. Store versioned fixtures, validate them with the same schema, and reuse them. This stops mocks from becoming fantasy data.
Contract drift check for mocks
import fs from 'node:fs';
import path from 'node:path';
import { test, expect } from '@playwright/test';
import { OrderSchema } from '../contracts/order.contract';
for (const file of ['order.v1.valid.json', 'order.v2.valid.json']) {
test(`mock ${file} still matches the contract`, async () => {
const json = JSON.parse(fs.readFileSync(path.join('tests/mocks/contracts', file), 'utf8'));
expect(OrderSchema.safeParse(json).success).toBe(true);
});
}
This small check catches a surprising number of bad mocks. I have seen teams mock fields that no backend ever returned. The UI tests passed, but production failed because the mocks trained the frontend against fake reality.
CI reporting and release gates
Contract tests should run early in CI. They should also be strict about product risk and calm about known provider work in progress. The worst contract suite is one that blocks every release for cosmetic drift.
GitHub Actions example
name: playwright-contracts
on:
pull_request:
push:
branches: [main]
jobs:
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=contracts
env:
APP_URL: ${{ secrets.QA_APP_URL }}
API_TOKEN: ${{ secrets.QA_API_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-contract-report
path: playwright-report
This builds on the CI patterns from Day 12 on Playwright CI with GitHub Actions. Keep contract artifacts even when the job fails. The report usually contains the exact field mismatch, response evidence, and the test step that broke.
Release gate rules
- Block release when a required field disappears.
- Block release when a field type changes without a migration plan.
- Block release when enum values change and the UI has no fallback.
- Warn, but do not always block, when optional additive fields appear.
- Do not block for test-data-only records unless the seed data contract is part of the release risk.
Version the contract decision
Add a small metadata block near each contract. This is boring, but it saves arguments later.
export const OrderContractMeta = {
owner: 'checkout-web',
provider: 'orders-service',
version: '2026-08-08',
breakingChangePolicy: 'required-fields-and-types-block-release'
} as const;
When a backend team needs to change the response, they know who owns the consumer expectation. Contract tests are not only code. They are a communication tool.
Common pitfalls
Here are the mistakes I see when teams add Playwright contract testing with TypeScript for the first time.
Pitfall 1: Validating too much
Do not turn every API response into a 300-line schema on day one. Start with the fields the UI genuinely consumes. If the order page uses id, status, total, and items, validate those first. Add more only when there is a consumer risk.
Pitfall 2: Confusing sample data with a contract
A single JSON fixture is not a contract. It is one example. The contract is the rule set behind that example: required fields, types, allowed values, null rules, date format, and nested structures.
Pitfall 3: Hiding errors behind helpers
Shared helpers are useful, but do not hide the validation failure. A helper named expectValidOrder() should print the schema error, endpoint, status code, and a sanitized response snippet. Otherwise everyone opens the CI log and guesses.
Pitfall 4: Ignoring nullability
Nullability breaks frontends. Be explicit. If discountCode can be null, write that in the schema. If it cannot be null, fail loudly when it is null. Do not let optional fields become a dumping ground for unclear product behavior.
Pitfall 5: Running contracts against unstable data
Contract tests should not depend on whatever a previous tester created. Use seeded records, API setup, or dedicated fixtures. The test-data rules from Day 45 apply here: unique data per worker, cleanup ownership, and no hidden dependency on production-like randomness.
Key takeaways
Playwright contract testing with TypeScript is a practical way to catch broken API agreements before they become flaky UI failures.
- Use Playwright’s
requestfixture for fast API-level contract checks. - Use Zod when the test or frontend team owns runtime contracts.
- Use AJV when JSON Schema or OpenAPI is the shared source of truth.
- Connect contracts to UI flows only where the browser depends on that response shape.
- Run contract checks early in CI and attach sanitized evidence to the report.
If you are building a Playwright framework for real teams, do not stop at locators and page objects. Add contracts around the API responses your UI cannot live without. That is the difference between a script writer and an SDET who protects releases.
FAQ
Is Playwright a contract testing tool?
Playwright is not a dedicated contract testing platform, but Playwright Test can run practical contract checks through its APIRequestContext and request fixture. For many QA teams, that is enough to start validating response shapes, status codes, headers, and business rules.
Should I use Zod or AJV for Playwright contract testing with TypeScript?
Use Zod when you want TypeScript-friendly runtime contracts inside the test repo. Use AJV when your team already uses JSON Schema or OpenAPI. Do not use both for the same endpoint unless you have a clear ownership reason.
Can contract tests replace E2E tests?
No. Contract tests reduce the number of browser tests needed for data-shape checks, but they do not prove that the user experience works. Keep a small set of E2E tests for critical workflows.
Where should contract tests run in CI?
Run them before browser-heavy suites. If contracts fail, the UI suite will likely produce noisy failures. A separate contracts project in Playwright keeps the gate clean.
