QA How-To
OpenAPI Contract Testing with Playwright TypeScript (2026)
Learn openapi contract testing with playwright typescript using Ajv, typed fixtures, negative checks, clear diagnostics, and a practical CI workflow for 2026.
22 min read | 2,270 words
TL;DR
Load and dereference an OpenAPI 3.1 document, use Playwright's APIRequestContext to call the service, and compile the selected response schema with Ajv 2020. Assert status and content type first, then validate the parsed body and print precise schema errors.
Key Takeaways
- Use Playwright for HTTP execution and Ajv 2020 for OpenAPI 3.1 payload validation.
- Dereference the contract once, then select schemas by path, method, status, and media type.
- Assert transport facts separately so a wrong status never appears as a vague schema error.
- Format Ajv errors with operation and instance paths to make CI failures actionable.
- Prove the validator with an intentionally invalid payload before trusting a green suite.
- Run contract checks against a deterministic environment and pin the reviewed OpenAPI artifact.
OpenAPI contract testing with Playwright TypeScript checks whether a live HTTP response matches the interface your API publishes. Playwright sends the request and asserts transport behavior, while an OpenAPI-aware helper selects the promised response schema and Ajv validates the JSON body.
This tutorial builds that workflow around a small Users API. You will run a local deterministic service, validate a real 200 response, cover a documented 404, prove that malformed data fails, and add the suite to CI. For the broader schema concepts behind this implementation, read the OpenAPI schema testing guide.
The important boundary is simple: Playwright is the test runner and HTTP client, not an OpenAPI validator. Ajv validates JSON Schema, but it does not choose an operation from an HTTP exchange. The helper you build connects those responsibilities explicitly.
What You Will Build
By the end, you will have:
- An OpenAPI 3.1 contract for
GET /users/{userId}with 200 and 404 responses. - A tiny Node server that returns deterministic success, error, and deliberately broken payloads.
- A reusable TypeScript fixture that loads and dereferences the contract once per worker.
- Playwright tests for status, media type, and JSON Schema conformance.
- Readable validation failures that point to the exact property and broken rule.
- A CI job that runs without installing browser binaries because these tests use only Playwright's request fixture.
The finished flow is APIResponse -> status and media type checks -> response schema selection -> Ajv validation -> focused failure report. This division prevents an undocumented 500 response from being misleadingly validated against a 200 schema.
Prerequisites
Use Node.js 24 LTS and npm 11. The example pins @playwright/test 1.60.0, @apidevtools/swagger-parser 12.1.0, ajv 8.17.1, ajv-formats 3.0.1, tsx 4.20.6, and TypeScript 5.9.2. Keep those versions in the lockfile so local and CI behavior stays identical.
Create a clean project:
mkdir playwright-openapi-contracts
cd playwright-openapi-contracts
npm init -y
npm install -D @playwright/test@1.60.0 typescript@5.9.2 tsx@4.20.6 @types/node@24.3.0
npm install @apidevtools/swagger-parser@12.1.0 ajv@8.17.1 ajv-formats@3.0.1
You do not need npx playwright install for this API-only suite. No browser launches, and APIRequestContext works independently. If you later combine browser and API checks, follow the normal browser installation process for your CI image. The Playwright APIRequestContext guide explains cookie sharing and isolated request contexts in more depth.
Verify the toolchain:
node --version
npm --version
npx playwright --version
Expect Node v24.x, npm 11.x, and Playwright Version 1.60.0. Exact application dependencies can advance later, but change them through a reviewed lockfile update and rerun the negative proof test in Step 7.
Step 1: Create the OpenAPI Contract
Create contracts/users.openapi.yaml. The contract closes both response objects with additionalProperties: false, which catches accidental fields as well as missing or mistyped ones.
openapi: 3.1.1
info:
title: Users API
version: 1.0.0
servers:
- url: http://127.0.0.1:3101
paths:
/users/{userId}:
get:
operationId: getUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
minLength: 1
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
components:
schemas:
User:
type: object
additionalProperties: false
required: [id, email, role, active]
properties:
id:
type: string
pattern: '^usr_[0-9]+#39;
email:
type: string
format: email
role:
type: string
enum: [admin, tester, viewer]
active:
type: boolean
Problem:
type: object
additionalProperties: false
required: [type, title, status]
properties:
type:
type: string
format: uri
title:
type: string
minLength: 1
status:
type: integer
minimum: 400
maximum: 599
OpenAPI 3.1 uses JSON Schema semantics. For nullable data, include null in the allowed type rather than using OpenAPI 3.0's nullable: true. This contract has no nullable property, which keeps the first example focused.
Verify Step 1: Run npx tsx -e "import p from '@apidevtools/swagger-parser'; p.validate('contracts/users.openapi.yaml').then(x => console.log(x.info.title))". The command must print Users API. A YAML parse success alone is insufficient because broken references and invalid OpenAPI structure can still exist.
Step 2: Build a Deterministic Test API
Create src/server.ts. The broken query parameter is intentionally included only to demonstrate that the test really detects drift. Do not add similar backdoors to production services.
import { createServer } from 'node:http';
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1:3101');
const match = url.pathname.match(/^\/users\/(.+)$/);
if (request.method !== 'GET' || !match) {
response.writeHead(404).end();
return;
}
const userId = decodeURIComponent(match[1]);
if (userId === 'missing') {
response.writeHead(404, { 'content-type': 'application/problem+json' });
response.end(JSON.stringify({
type: 'https://example.test/problems/not-found',
title: 'User not found',
status: 404
}));
return;
}
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
response.end(JSON.stringify({
id: userId,
email: 'qa@example.test',
role: 'tester',
active: url.searchParams.get('broken') === 'true' ? 'yes' : true
}));
});
server.listen(3101, '127.0.0.1', () => {
console.log('Users API listening on http://127.0.0.1:3101');
});
Add scripts to package.json:
{
"scripts": {
"api:start": "tsx src/server.ts",
"test:contract": "playwright test"
}
}
Verify Step 2: Start the service with npm run api:start, then run curl -i http://127.0.0.1:3101/users/usr_42. Expect status 200, an application/json content type, and an object whose active value is the Boolean true. Keep the server running until Playwright manages it in the next step.
Step 3: Configure Playwright for API Tests
Create playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
fullyParallel: true,
use: {
baseURL: 'http://127.0.0.1:3101',
extraHTTPHeaders: { accept: 'application/json, application/problem+json' }
},
webServer: {
command: 'npm run api:start',
url: 'http://127.0.0.1:3101/users/usr_1',
reuseExistingServer: !process.env.CI,
timeout: 30_000
},
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'list'
});
baseURL lets tests call /users/usr_42 rather than repeat the host. webServer owns startup and shutdown, so a forgotten terminal process cannot determine test success. The readiness URL must return any HTTP response; Playwright treats status codes from 200 through 399, plus several common non-success readiness statuses, as available. Here it returns 200.
The HTML report is useful in CI, but the line reporter ensures schema messages remain visible in job logs. Contract tests often fail before a browser trace would add value, so retain the actual validator error text.
Verify Step 3: Stop the manually started server and run npx playwright test --list. Playwright should start without asking for browsers and list tests once you create them. At this moment, Error: No tests found is acceptable and confirms the config loaded. A TypeScript import error is not acceptable.
Step 4: Build the OpenAPI Response Validator
Create tests/support/openapi-validator.ts. It dereferences local $ref values, normalizes a content type such as application/json; charset=utf-8, selects the documented status, and compiles schemas lazily.
import SwaggerParser from '@apidevtools/swagger-parser';
import Ajv2020, { type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
type ApiDocument = {
paths: Record<string, Record<string, any>>;
};
export class OpenApiValidator {
private readonly ajv = new Ajv2020({ allErrors: true, strict: false });
private readonly validators = new Map<string, ValidateFunction>();
private constructor(private readonly document: ApiDocument) {
addFormats(this.ajv);
}
static async load(file: string): Promise<OpenApiValidator> {
const document = await SwaggerParser.dereference(file) as ApiDocument;
return new OpenApiValidator(document);
}
validateResponse(input: {
path: string;
method: HttpMethod;
status: number;
contentType: string;
body: unknown;
}): ErrorObject[] {
const operation = this.document.paths[input.path]?.[input.method];
if (!operation) throw new Error(`Undocumented operation: ${input.method.toUpperCase()} ${input.path}`);
const response = operation.responses?.[String(input.status)]
?? operation.responses?.default;
if (!response) throw new Error(`Undocumented status ${input.status} for ${input.method.toUpperCase()} ${input.path}`);
const mediaType = input.contentType.split(';', 1)[0].trim().toLowerCase();
const schema = response.content?.[mediaType]?.schema;
if (!schema) throw new Error(`Undocumented media type ${mediaType} for status ${input.status}`);
const key = `${input.method}:${input.path}:${input.status}:${mediaType}`;
let validate = this.validators.get(key);
if (!validate) {
validate = this.ajv.compile(schema);
this.validators.set(key, validate);
}
return validate(input.body) ? [] : [...(validate.errors ?? [])];
}
}
The helper accepts an OpenAPI path template, not the resolved URL. Mapping /users/usr_42 back to /users/{userId} is explicit in this small suite. A general framework needs a path-template matcher and must resolve ambiguous paths consistently. It should also support status ranges and empty responses if the contract uses them.
strict: false avoids Ajv strict-mode conflicts with OpenAPI annotations. It does not make data validation lenient. In a mature framework, review every strict warning and narrow the relaxation rather than copying this setting blindly.
Verify Step 4: Run npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2023 tests/support/openapi-validator.ts. Expect no TypeScript errors. If your project already has a tsconfig.json, use its normal npm run typecheck command instead.
Step 5: Add a Typed Playwright Fixture
Loading and dereferencing YAML in every test wastes time and can produce inconsistent setup failures. Create tests/fixtures.ts with a worker-scoped validator:
import { test as base } from '@playwright/test';
import { OpenApiValidator } from './support/openapi-validator';
type WorkerFixtures = {
openapi: OpenApiValidator;
};
export const test = base.extend<{}, WorkerFixtures>({
openapi: [async ({}, use) => {
const validator = await OpenApiValidator.load('contracts/users.openapi.yaml');
await use(validator);
}, { scope: 'worker' }]
});
export { expect } from '@playwright/test';
Worker scope is safe because the dereferenced document and compiled validators are read-only after construction. Each parallel worker gets its own instance, so there is no cross-process synchronization. The fixture also makes the contract dependency visible in every test signature.
Avoid hiding contract validation in a global response listener. Listeners tend to validate unrelated analytics, health, and third-party traffic, then report errors far from the operation under test. An explicit call communicates which exchange is part of the contract assertion. For more request fixture patterns, see Playwright APIRequestContext examples.
Verify Step 5: Temporarily create tests/fixture.spec.ts containing import { test } from './fixtures'; test('loads', async ({ openapi }) => { if (!openapi) throw new Error('missing'); });. Run npx playwright test tests/fixture.spec.ts, expect one passed test, then remove that temporary file.
Step 6: Test the 200 and 404 Contracts
Create tests/users.contract.spec.ts:
import { test, expect } from './fixtures';
function formatErrors(errors: Array<{ instancePath: string; message?: string; params: unknown }>): string {
return errors.map(error =>
`${error.instancePath || '/'} ${error.message ?? 'is invalid'} ${JSON.stringify(error.params)}`
).join('\n');
}
test('GET /users/{userId} returns a contract-valid user', async ({ request, openapi }) => {
const response = await request.get('/users/usr_42');
await expect(response).toBeOK();
expect(response.status()).toBe(200);
const contentType = response.headers()['content-type'] ?? '';
expect(contentType).toContain('application/json');
const body: unknown = await response.json();
const errors = openapi.validateResponse({
path: '/users/{userId}',
method: 'get',
status: response.status(),
contentType,
body
});
expect(errors, formatErrors(errors)).toEqual([]);
});
test('GET /users/{userId} returns the documented problem', async ({ request, openapi }) => {
const response = await request.get('/users/missing');
expect(response.status()).toBe(404);
const contentType = response.headers()['content-type'] ?? '';
expect(contentType).toContain('application/problem+json');
const body: unknown = await response.json();
const errors = openapi.validateResponse({
path: '/users/{userId}',
method: 'get',
status: response.status(),
contentType,
body
});
expect(errors, formatErrors(errors)).toEqual([]);
});
Transport assertions come first. toBeOK() is appropriate only for the success case because it accepts 200 through 299. The 404 test asserts its exact status. Both then pass the actual status and content type into schema selection, so an error body cannot be checked against User by mistake.
Verify Step 6: Run npm run test:contract. Expect 2 passed. If the second test fails at response.json(), inspect its content type and raw await response.text() because a proxy may be returning HTML.
Step 7: Prove the OpenAPI Contract Test Can Fail
A validator that has never failed is unproven. Add this test to tests/users.contract.spec.ts:
test('rejects a response that violates the User schema', async ({ request, openapi }) => {
const response = await request.get('/users/usr_42?broken=true');
const contentType = response.headers()['content-type'] ?? '';
const body: unknown = await response.json();
const errors = openapi.validateResponse({
path: '/users/{userId}',
method: 'get',
status: response.status(),
contentType,
body
});
expect(errors).toEqual(expect.arrayContaining([
expect.objectContaining({ instancePath: '/active', keyword: 'type' })
]));
});
This is a validator characterization test, not a normal endpoint expectation. The broken response sets active to the string yes, while the contract requires a Boolean. Testing the structured Ajv error is more stable than matching its entire human-readable message.
You can also temporarily change the first success test to call ?broken=true. Its failure should include /active must be boolean, proving the production-style assertion reports a useful property path. Revert that temporary change immediately.
Verify Step 7: Run npm run test:contract. Expect 3 passed. Then run the temporary mutation described above and confirm exactly one test fails for /active; restore the URL and rerun to green. This red-green check catches miswired validators that silently skip missing schemas or validate the wrong object.
Step 8: Add OpenAPI Contract Testing with Playwright TypeScript to CI
Create .github/workflows/contract-tests.yml:
name: API contract tests
on:
pull_request:
push:
branches: [main]
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:contract
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-contract-report
path: playwright-report/
if-no-files-found: ignore
Do not install Chromium merely because Playwright is a dependency. This suite uses the request fixture, so skipping browser downloads saves setup time and removes an unrelated failure source. Pin action revisions according to your organization's supply-chain policy.
Use the reviewed contract from the same commit as the service when possible. Fetching a mutable /openapi.json from a shared environment can make an old test run against a new description. If the provider publishes a versioned artifact, download it by immutable version and record that version in the report.
Contract checks answer a different question from generated TypeScript types. Types protect test code at compile time; runtime validation inspects actual untrusted JSON. Keep both if clients are generated. The guide to generating API tests from OpenAPI with AI can help expand case ideas, but each generated assertion still needs review.
Verify Step 8: Push a branch and inspect the workflow. It should install from package-lock.json, start the API through Playwright's webServer, report three passing tests, and upload the HTML report even when a later run fails.
Choosing the Right Validation Approach
| Approach | Best use | What it misses | Maintenance cost |
|---|---|---|---|
| Handwritten field assertions | Important business fields | Unasserted properties and broad drift | Grows with every field |
| Ajv plus explicit schema selection | Small, transparent test frameworks | Automatic path matching and parameter rules | Moderate, visible code |
| OpenAPI-aware middleware | Broad operation coverage | Product semantics and authorization | Library-specific integration |
| Generated TypeScript client | Compile-time request and response ergonomics | Runtime server drift without validation | Regeneration and review |
| Consumer-driven contracts | Concrete consumer expectations | Complete provider surface by default | Provider-consumer coordination |
The tutorial's explicit helper is ideal when you want understandable behavior and a limited API surface. For hundreds of operations, adopt a maintained OpenAPI-aware adapter rather than expanding a homegrown selector indefinitely. Evaluate its OpenAPI 3.1 support, $ref handling, media-type matching, status ranges, request serialization, error quality, and release cadence.
OpenAPI and consumer-driven contracts are complementary. OpenAPI describes the provider surface, while consumer contracts capture interactions a particular client depends on. Read the API contract testing with Pact guide before choosing one as a substitute for the other.
Best Practices
- Validate the OpenAPI document before using any nested schema. A broken
$refshould fail setup, not turn into a skipped assertion. - Assert exact status and expected media type before parsing JSON. HTTP 404 and 500 responses are successful network exchanges, not successful API outcomes.
- Compile each response schema once per worker. Recompiling for every request adds noise without improving coverage.
- Preserve the operation, status, media type, instance path, schema keyword, and contract version in failures. Redact secrets and personal data.
- Test documented errors, headers, and empty responses, not just the happy-path JSON body.
- Add semantic assertions after schema validation. A price can be a valid number and still be incorrect.
- Keep test data deterministic and isolate state-changing requests. Parallel workers should not race over shared records.
- Treat
additionalProperties: falseas an evolution decision. It catches leakage, but an additive provider field becomes a breaking change for strict validation. - Pair shape checks with authorization tests. A perfectly shaped response can still expose another tenant's record. See API security testing basics.
Interview Questions and Answers
The model answers in the structured interview section cover the main design decisions: Playwright's role, Ajv's role, operation selection, diagnostics, negative proof, and CI placement. In an interview, explain the boundary each tool owns and give one concrete failure example instead of saying only that you validate schemas.
Troubleshooting
Problem: Cannot find module ajv/dist/2020.js -> Confirm ajv@8.17.1 is installed and use NodeNext module resolution. Do not import the default Draft 7 entry point for an OpenAPI 3.1 contract that depends on Draft 2020-12 semantics.
Problem: unknown format email ignored or format validation never fails -> Install ajv-formats and call addFormats(ajv) before compiling schemas. Add a characterization test with an invalid email so the setting remains observable.
Problem: the validator reports an undocumented media type -> Read response.headers()['content-type'], normalize it before the semicolon, and compare the base media type. If the service genuinely returns application/problem+json, document that exact type instead of forcing it through application/json.
Problem: every test recompiles schemas and becomes slow -> Move contract loading into a worker fixture and cache compiled validators by method, path template, status, and media type. Never mutate the dereferenced document during a test.
Problem: a 404 body fails against the success schema -> Select operation.responses[String(response.status())] from the actual response. Do not hard-code the 200 schema, and fail clearly when the status is undocumented.
Problem: local tests pass but CI cannot reach the API -> Let Playwright webServer start the service on 127.0.0.1, use the same port in baseURL, and avoid relying on a shell process left running locally. Check the server command's CI environment variables and preserve its startup logs.
Where To Go Next
Extend the validator one capability at a time. Add 204 handling before testing delete operations, status-range matching before using 2XX, and a path-template matcher before iterating across operations. Then validate requests, including path parameters, query serialization, headers, and request bodies.
Use Playwright APIRequestContext patterns for authentication and isolated cookies, and revisit OpenAPI schema testing for composition, nullable values, compatibility checks, and contract drift. Practice explaining these trade-offs with API testing interview questions.
Finally, connect the suite to a versioned contract artifact and run it at two points: before merge against the built service, and after deployment against a controlled environment. Start with one high-risk operation, make its errors excellent, then expand coverage by business risk rather than endpoint count.
Interview Questions and Answers
How would you implement OpenAPI contract testing with Playwright TypeScript?
I use Playwright's request fixture to execute an operation, assert transport facts, then select the response schema by path template, method, actual status, and normalized media type. I dereference the OpenAPI document during worker setup and compile the selected schema with Ajv 2020. A failure reports the operation, instance path, keyword, and contract version.
What is Playwright responsible for in this contract test design?
Playwright owns test lifecycle, HTTP execution, fixtures, assertions, parallelism, reporting, and service startup. It does not understand OpenAPI schemas automatically. Keeping that boundary explicit prevents a generic HTTP assertion from being mistaken for contract validation.
Why must response schema selection use the actual status code?
An operation can promise different bodies for 200, 404, and other statuses. Applying the 200 schema to an error creates misleading failures, while skipping unexpected statuses hides drift. I assert status intent first and then select the matching documented response.
How do you make OpenAPI validation failures useful in CI?
I include the method, path template, status, media type, JSON instance path, failing keyword, and immutable contract version. I keep the line reporter enabled and attach the HTML report. Values are bounded or redacted so diagnostics do not leak tokens or personal data.
How do you prove that a contract validator is correctly wired?
I run a characterization test with one intentional violation, such as a string where the schema requires a Boolean. The test asserts the structured error path and keyword. I also temporarily run the normal conformance assertion against that payload to observe a red test before restoring green.
What are the limits of OpenAPI response validation?
It can catch missing fields, wrong types, invalid formats, unexpected properties, and other declared constraints. It cannot prove a total is calculated correctly, a state transition occurred, or the caller is authorized to see the object. Those require semantic, state, and security tests.
Frequently Asked Questions
Can Playwright validate an API response against OpenAPI by itself?
No. Playwright sends HTTP requests and provides response assertions, but it does not interpret OpenAPI response schemas. Add an OpenAPI-aware selection layer and a JSON Schema validator such as Ajv.
Do Playwright API contract tests need browser binaries?
No, not when the suite uses only the request fixture or APIRequestContext. Browser installation is needed only when tests launch Chromium, Firefox, or WebKit.
Should I validate only successful API responses?
No. Validate every important documented response, including 400, 401, 403, 404, conflict, rate-limit, and server-error shapes where applicable. Select the schema from the actual status and media type.
Why use Ajv 2020 for an OpenAPI 3.1 contract?
OpenAPI 3.1 aligns its Schema Object with JSON Schema Draft 2020-12. Ajv's 2020 entry point applies that dialect, while the default Ajv entry point targets an older draft.
Should the test download the OpenAPI document from the live API?
Prefer an immutable contract artifact tied to the build under test. A mutable live URL can change independently and make results impossible to reproduce.
Does schema validation replace API functional testing?
No. Schema validation proves shape and declared constraints, not business calculations, authorization, state transitions, or side effects. Add focused semantic and security assertions after contract conformance.