Contract testing for GraphQL: A beginner’s guide with Pactflow, Playwright and TypeScript
Have you ever deployed your frontend only to have it break because the backend team changed an API field? Or spent hours debugging integration issues that could have been caught much earlier? If you work in a microservices environment, you’ve likely felt this frustration.
Consumer-driven contract testing (CDCT) offers a better way. Using Pactflow, Playwright, and TypeScript, you can ensure your frontend stays in sync with backend APIs — without the need for full integration environments.
In this guide, I’ll walk you through how CDCT works, why we chose it, and how to implement it effectively for GraphQL.
💥 The Problem with Traditional Integration Testing
Before diving into contract testing, it’s important to understand why traditional integration or end-to-end (E2E) testing often falls short:
- All services must be running — mocks aren’t used
- Tests are slow, flaky, and hard to maintain
- Failures are difficult to reproduce
- Service dependencies block frontend development
- Complex test data setup slows teams down
✅ What Is Contract Testing?
Contract testing focuses on verifying the agreement (or “contract”) between a consumer (e.g., a frontend app) and a provider (e.g., a backend service). Instead of spinning up all services, each side tests its part of the contract in isolation using mocks and test doubles.
🔄 Why contract testing?
Traditional integration tests often follow a provider-first approach: the backend defines the API, and consumers must adapt. But this doesn’t work well when:
- Backend teams make changes unaware of frontend impact
- Frontend teams discover breaking changes too late
- Mobile apps can’t be updated instantly via app stores
- Web apps silently fail, degrading user experience
CDCT flips the model: the consumer defines what it needs, and the provider verifies it can support those requirements.
🧭 Contract Testing Models Compared
1. Consumer-Driven (Our Approach)
- Frontends define the contract based on real usage
- Providers verify they can meet those expectations
- Enables fast feedback and consumer autonomy
- Encourages only necessary API surface to be tested
👉 Why we chose this: Our frontend teams iterate quickly and independently. CDCT ensures backend teams deliver only what’s needed — no more, no less. It’s ideal for microservices and mobile/web clients that need tailored responses.
2. Provider-Driven
- Backend defines the contract up front
- Consumers must conform
- Good for tightly controlled systems
⚠️ Why not this: Too rigid for fast-moving consumer teams. It slows down development and often results in over-specified or under-used APIs.
3. Bi-Directional
- Relies on shared specs (e.g., OpenAPI)
- Both sides validate against the same spec
🤝 Why not this (yet): Useful in API-first orgs with strong governance, but adds overhead. It’s less flexible during early product development.
⚙️ Setting Up Consumer-Driven GraphQL Contract Testing
Let’s walk through an e-commerce example where a frontend defines the contract for a GraphQL API.
Step 1: Consumer Side — defining what we need
The consumer (web/mobile application) defines its contract by writing tests that specify exactly what it expects from the GraphQL API:
// consumer-test.spec.ts
import { test, expect } from '@playwright/test'
import { pact, like } from './pact.setup'
const PRODUCT_QUERY = `
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price {
amount
currency
}
inStock
}
}
`
test('Get product information', async ({ request }) => {
// This is what our web app expects from the API
const expectedResponse = {
data: {
product: {
id: 'prod-123',
name: 'Wireless Headphones',
price: {
amount: '199.99',
currency: 'USD'
},
inStock: true
}
}
}
// Define the contract: "When I send this request, I expect this response"
pact
.given('product with id prod-123 exists')
.uponReceiving('a request for product details')
.withRequest({
method: 'POST',
path: '/graphql',
headers: { 'Content-Type': 'application/json' },
body: {
query: PRODUCT_QUERY,
variables: { id: like('prod-123') }
}
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: like(expectedResponse)
})
// Test against Pact's mock server
return pact.executeTest(async (mockServer) => {
const response = await request.post(`${mockServer.url}/graphql`, {
data: { query: PRODUCT_QUERY, variables: { id: 'prod-123' } }
})
const data = await response.json()
expect(data.data.product.name).toBe('Wireless Headphones')
expect(data.data.product.inStock).toBe(true)
})
})🔍 What This Does:
- Defines expectations: The frontend specifies required fields
- Mocks the backend: Pact creates a mock server with the expected response
- Generates a contract: Pact builds a JSON contract from the interaction
The key insight: The consumer drives what the contract looks like based on real application needs, not theoretical API design.
Step 2: Contract publication to Pactflow
Once tests run, we publish the generated contract to Pactflow for the provider to verify:
// publish-pacts.ts
import { execSync } from 'child_process'
const publishContracts = () => {
const gitCommit = process.env.CI_COMMIT || execSync('git rev-parse HEAD').toString().trim()
const gitBranch = process.env.CI_BRANCH || execSync('git rev-parse --abbrev-ref HEAD').toString().trim()
const command = `npx pact-broker publish ./pacts \
--broker-base-url="https://your-org.pactflow.io" \
--broker-token="${process.env.PACT_BROKER_TOKEN}" \
--consumer-app-version="${gitCommit}" \
--branch="${gitBranch}"`
execSync(command, { stdio: 'inherit' })
console.log('Pacts published successfully.')
}Keep in mind you need to set up a pactflow broker and get the API key (PACT_BROKER_TOKEN) for this to work.
🔍 Pactflow’s role:
- Store versioned contracts
- Track contracts per Git branch
- Trigger tests via webhooks
- Coordinate safe deployments with
can-i-deploy
You can view the published contract, versioned using the Git commit hash, under the Matrix tab in your Pactflow application. The contract itself is a JSON file that captures the request and expected response defined in your consumer test.
Step 3: Provider side — verifying against real implementation
Here’s where consumer-driven testing shines: the provider verifies against the actual running GraphQL server, not mocks:
// provider-verification.spec.ts
import { Verifier } from '@pact-foundation/pact'
import { test } from '@playwright/test'
test('verify consumer requests are supported by provider', async () => {
const opts = {
provider: 'ecommerce-api',
providerBaseUrl: 'http://localhost:4000', // Real GraphQL server
// Fetch contracts from Pactflow
pactBrokerUrl: 'https://your-org.pactflow.io',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Verify latest consumer contracts
consumerVersionSelectors: [
{ latest: true },
{ deployedOrReleased: true }
],
// Publish verification results back to Pactflow
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
// Set up test data for each consumer scenario
stateHandlers: {
'product with id prod-123 exists': async () => {
// Set up real data in database/server (if not already present)
await seedTestProduct({
id: 'prod-123',
name: 'Wireless Headphones',
price: { amount: '199.99', currency: 'USD' },
inStock: true
})
}
},
// Add authentication to requests (if a logged in user is required)
requestFilter: (req, res, next) => {
if (req.path === '/graphql') {
req.headers.authorization = `Bearer ${testAuthToken}`
}
next()
}
}
await new Verifier(opts).verifyProvider()
})In this step, the provider fetches the consumer contract from Pactflow and replays the interactions against the live GraphQL server to verify that it can fulfill the consumer’s expectations. This ensures the provider’s actual implementation aligns with what the consumer depends on.
Pactflow handles much of the complexity, managing contract versions, running deployment checks, and triggering verification tests via webhooks. When integrated into your CI pipeline, it becomes a robust and automated validation workflow. For a deeper dive, I recommend exploring the official pact documentation.
🧠 Why This Works for Frontend Apps
1. Platform-Specific Contracts
Your web and mobile applications define exactly what they need:
// Mobile app contract - optimized for limited bandwidth
const MOBILE_PRODUCT_QUERY = `
query GetProductMobile($id: ID!) {
product(id: $id) {
id
name
price { amount currency }
# Only essential fields for mobile
}
}
`// Web app contract - includes richer data
const WEB_PRODUCT_QUERY = `
query GetProductWeb($id: ID!) {
product(id: $id) {
id
name
description
price { amount currency }
images { url alt }
reviews { rating comment }
# Richer experience for web
}
}
`Each client defines its needs — no over-fetching or guessing.
2. Breaking Change Prevention
When Pactflow is integrated into your CI/CD pipeline, contract tests typically run immediately after unit tests. This setup ensures that any breaking change to the API, such as removing a field, will cause the contract verification or can-i-deploy check to fail.
For example, if the backend team attempts to remove a field like inStock from the Product type, the provider verification step will fail because the consumer contract still expects that field. This immediate feedback prevents unintended regressions from reaching production.
# Backend wants to remove 'inStock' field
type Product {
id: ID!
name: String!
price: Money!
# inStock: Boolean! ← Wants to remove this
}If the backend removes a field (inStock), the provider verification fails. Pactflow flags the issue before deployment, preventing regressions.
3. Safe Schema Evolution
Add new fields to the contract, verify them on the provider side, and deploy with confidence.
// Consumer can add new fields to their contract
const ENHANCED_PRODUCT_QUERY = `
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price { amount currency }
inStock
categories # New field
rating # New field
}
}
`🚀 Pactflow: The Contract Coordination Hub
Pactflow manages the entire consumer-driven workflow:
1. Version Tracking
# Each consumer version is tracked
web-client v1.2.3 → contract-abc123.json
mobile-app v2.1.0 → contract-def456.json
admin-panel v1.5.1 → contract-ghi789.json2. Can-I-Deploy Checks
Before deploying either consumer or provider:
# Consumer check: Is my contract verified by the provider?
npx pact-broker can-i-deploy \
--pacticipant web-client \
--version $GIT_COMMIT \
--to-environment production# Provider check: Can I safely deploy without breaking consumers?
npx pact-broker can-i-deploy \
--pacticipant ecommerce-api \
--version $GIT_COMMIT \
--to-environment productionThese commands prevent deployments that would break the contract. This enables sophisticated deployment strategies and rollback decisions.
3. Audit trail
View contract history and test results
✅ Best Practices
1. Start with Critical User Journeys
Focus on the most important flows first:
// Start with core functionality
test('user can search and view products') // Essential
test('user can add items to cart') // Essential
test('user can complete checkout') // Essential
// Add edge cases later
test('user can apply discount codes') // Secondary
test('user can save items for later') // Secondary2. Use Meaningful State Descriptions
// Good: Descriptive states
.given('user has items in cart and valid payment method')
.given('product is in stock with quantity greater than 5')
.given('user is authenticated with premium subscription')
// Bad: Vague states
.given('user exists')
.given('data is available')
.given('system is ready')⚠️ Common Pitfalls
🏁 Conclusion: Build With Confidence
Consumer-driven contract testing transforms collaboration:
Before CDCT:
Backend: “Here’s the API.”
Frontend: “This breaks everything.”
After CDCT:
Frontend: “Here’s what we need.”
Backend: “We can support that — verified!”
✨ Benefits Recap
- Faster development with fewer blockers
- Better UX through tailored APIs
- Fewer regressions caught early in CI
- Clearer communication via living contracts
For modern frontend apps, CDCT with Pact and Pactflow offers a scalable, reliable way to ship with confidence. Start with one critical user journey, build your contract safety net, and expand from there.
Your frontend leads. Your backend adapts. Everyone wins.
