Introduction
Modern software systems right now are increasingly built using micro services and distributed architectures. In such envy, app often depend on APIs developed and maintained by different teams members. Even a small, unannounced change in an API response, such as modifying a field type or removing a property, can make face downstream failures that are difficult to find or detect early. This is where contract testing is used to solve issues and handle problems.
Here, Contract testing ensures, verifies, guarantees, and validates the stability of the communication agreement or contract between API consumers and providers remains stable over time. We provide a practical explanation of Contract testing using Playwright in this article and also implementation.
Here we cover:
We also go through a complete real-world example using Playwright and Pact frameworks, validating API structure, data types, and business rules step by step.
What Is Contract Testing?
Contract testing it is the type of testing approach where we can ensure, verifies, guarantees, validates, the agreement (or “contract”) between two interacting systems—typically an API consumer and an API provider. Instead of performing full E2E behaviour testing, contract tests focus only on the interface between services.
Producer vs Consumer
- Consumer: The service or application that sends requests and relies on the API response.
- Producer (Provider): The service that exposes the API and returns responses.
The contract defines:
- Expected request method and endpoint
- Required response fields
- Data types and structure
- Constraints such as non-empty values
Why does it prevent breaking changes?
Without contract testing, breaking API changes are often detected late—sometimes only in production. Contract testing prevents this by:
- Detecting schema changes early
- Enforcing type consistency
- Ensuring required fields are never removed
This makes contract testing a critical safety net in modern CI/CD pipelines.
Why use Playwright for Contract Testing?
Because PW tool commonly supports browser automation, it is also helpful and provides powerful API testing capabilities that make it suitable for contract testing.
Playwright strengths
- Native HTTP request support
- Strong assertion capabilities
- Fast execution
- Unified framework for UI and API testing
API request context usage
Playwright’s `APIRequestContext` allows us to:
- Send HTTP requests without a browser
- Test APIs independently of UI flows
- Validate responses with precise control
Cross-browser + API capability
One major advantage is that teams can:
- Perform UI tests
- Perform API and contract tests
- Maintain everything in one framework
This makes Playwright contract testing a practical option for teams aiming to reduce tooling overhead.
Types of Contract Testing supported
- Consumer-driven contracts
- Provider-driven contacts
In this model:
- The consumer defines expectations
- The provider must satisfy them
This aligns directly with consumer-driven contract testing Playwright implementations.
Schema validation
Contracts validate:
- Required vs optional fields
- Data types
- Nested structures
API Response Structure Validation
By validating arrays, objects, and nested fields, teams can ensure response consistency—an essential aspect of API contract testing with Playwright.
Setting up playwright for Contract Testing
Required installation setup
To create or implement contract testing with Playwright, we need:
- Playwright Test
- Pact library
- Node.js
- JSON fixtures for test data
Libraries & tools
- Playwright for request execution
- Pact for contract definition and verification
- MatchersV3 for flexible value matching
Together, these form one of the most effective contract testing tools for APIs.
Environment configuration
- A Pact mock server simulates the provider
- Playwright sends requests to the mock server
- Pact verifies interactions and generates contracts
Step-by-step: Contract Testing using playwright
The section below explains the complete test implementation, with each explanation directly to the code logic.
Full Contract Test Code (Unchanged)
import { test, expect, request } from "@playwright/test";
import path from "path";
import fs from "fs";
import { pact } from "../pact/pactConfig";
import { MatchersV3 } from "@pact-foundation/pact";
const { eachLike, like, integer } = MatchersV3;
// Load fixture
const fixturePath = path.join(__dirname, "../fixtures/LocationData.json");
const locationData = JSON.parse(fs.readFileSync(fixturePath, "utf-8"));
test.describe("Contract Test - GET /locations", () => {
let apiContext: any;
test.beforeAll(async () => {
await pact.setup();
apiContext = await request.newContext({
baseURL: "http://127.0.0.1:1234",
});
console.log("✅ Pact mock server started");
});
test("should return locations with correct structure and values", async () => {
await pact.addInteraction({
state: "locations data exists",
uponReceiving: "a request for locations",
withRequest: {
method: "GET",
path: "/locations",
},
willRespondWith: {
status: 200,
body: eachLike({
countryName: like(locationData[0].countryName),
phoneNumber: integer(locationData[0].phoneNumber),
address: eachLike(like(locationData[0].address[0])),
}) as any,
},
});
console.log("➡ Sending request to Pact mock server");
const response = await apiContext.get("/locations");
const responseData = await response.json();
if (response.status() === 200) {
console.log("✅ Response status is 200");
} else {
console.log(`❌ Response status is ${response.status()}`);
throw new Error("Contract failed: API status not 200");
}
// Response array check
if (Array.isArray(responseData)) {
console.log("✅ Response is array");
} else {
console.log("❌ Response is not array");
throw new Error("Contract failed: response is not array");
}
responseData.forEach((item: any, index: number) => {
console.log(`\n🔍 Validating response item index: ${index}`);
// countryName existence check
if ("countryName" in locationData[0]) {
console.log("✅ countryName field exists");
} else {
console.log("❌ CONTRACT ISSUE: countryName is missing in response");
throw new Error("Contract failed: countryName missing");
}
// countryName value & type check
if (item.countryName === "") {
console.log("❌ CONTRACT ISSUE: countryName should not be empty");
throw new Error("Contract failed: countryName empty");
}
else if (typeof item.countryName !== "string") {
console.log(
`❌ CONTRACT ISSUE: countryName type mismatch, expected string but received ${typeof item.countryName}`
);
throw new Error("Contract failed: countryName type mismatch");
}
else {
console.log("✅ countryName is valid string");
}
// phoneNumber existence
if ("phoneNumber" in locationData[0]) {
console.log("✅ phoneNumber field exists");
} else {
console.log("❌ CONTRACT ISSUE: phoneNumber is missing in response");
throw new Error("Contract failed: phoneNumber missing");
}
// phoneNumber value & type check
if (item.phoneNumber === "") {
console.log("❌ CONTRACT ISSUE: phoneNumber should not be empty");
throw new Error("Contract failed: phoneNumber empty");
}
else if (typeof item.phoneNumber === "string") {
console.log(
"❌ CONTRACT ISSUE: phoneNumber should be integer but received string"
);
throw new Error("Contract failed: phoneNumber string instead of integer");
}
else if (typeof item.phoneNumber === "number") {
console.log("✅ phoneNumber is valid integer");
}
else {
console.log(
`❌ CONTRACT ISSUE: phoneNumber invalid type ${typeof item.phoneNumber}`
);
throw new Error("Contract failed: phoneNumber invalid type");
}
// address must be array
if (!Array.isArray(item.address)) {
console.log(`❌ CONTRACT ISSUE: address should be array, but got ${typeof item.address}`);
throw new Error("Contract failed: address is not array");
}
// array should not be empty
if (item.address.length === 0) {
console.log("❌ CONTRACT ISSUE: address array should not be empty");
throw new Error("Contract failed: address empty array");
}
console.log("✅ address is valid array");
// address[0] type check
if (typeof item.address[0] !== "string") {
console.log(`❌ CONTRACT ISSUE: address[0] should be string but got ${typeof item.address[0]}`);
throw new Error("Contract failed: adress type mismatch");
} else {
console.log("✅ address[0] is valid string");
}
});
});
test.afterAll(async () => {
await pact.verify();
console.log("✅ Pact verification successful");
await pact.finalize();
console.log("✅ Pact file written & server stopped");
});
});
Explanation of the implementation
- Fixture loading ensures test data consistency and avoids hardcoding values.
- Pact interaction setup defines the consumer expectation using matchers instead of static values.
- API execution is handled by Playwright’s request context.
- Status and structure validation ensures the response meets minimum contractual guarantees.
- Field-level checks validate existence, type, and non-empty values.
- Explicit errors make contract violations easy to identify.
- Pact verification ensures the provider honors the contract before finalizing it.
This approach combines flexibility with strict validation, making Playwright API testing highly reliable for contract enforcement.
Best practices for playwright Contract Testing
- Version contracts carefully
- Integrate tests into CI pipelines
- Avoid over-validating response values
- Focus on interface stability, not implementation details
Most frequently asked question in FAQ
Yes. When combined with Pact, Playwright provides a clean and effective contract testing solution.
They solve different problems. Pact defines contracts, while Playwright executes and validates them.
When APIs are shared across teams or services and backward compatibility is critical.
Conclusion
Contract testing is essential for maintaining stable communication between Consume and Provider systems. By combining PW and Pact Framework, dev teams or QA team can validate API agreements early and detect/find breaking changes before they reach production/live. Using PW to enforce API contracts allows us to build confidence in integrations while keeping tests fast, readable, and maintainable.