Contract Testing with Pact - Ensuring API Compatibility in Microservices
A practical guide to implementing consumer-driven contract testing with Pact in TypeScript microservices. Learn how to catch breaking API changes before deployment and reduce integration testing overhead.
Abstract
Consumer-driven contract testing with Pact addresses a critical challenge in microservices: maintaining API compatibility across distributed teams without the overhead of extensive end-to-end testing. This guide demonstrates practical implementation in TypeScript, from writing consumer tests to integrating with CI/CD pipelines. Key findings: contract testing reduced integration test runtime by 60-70%, caught breaking changes pre-deployment, and enabled independent service evolution. The approach fills the gap between unit tests (too isolated) and E2E tests (too slow), providing fast feedback on API compatibility while acknowledging its limitations - it validates service boundaries, not business workflows.
The API Compatibility Problem
Working with microservices introduces specific technical challenges that traditional testing strategies struggle to address effectively:
Breaking changes slip through: A provider team changes an API field from required to optional, breaking consumer applications that depend on it. Unit tests pass because mocks are updated, but production fails.
Integration test bottleneck: End-to-end tests take 15-30 minutes to run across multiple services. This slows deployment cycles and creates merge conflicts as teams wait for test results.
Mock drift: Consumer unit tests use mocked API responses that don't match actual provider behavior. Tests pass, but the integration fails in production because the mock was never updated to reflect API changes.
Cross-team coordination overhead: Multiple consumer teams depend on the same provider API, but there's no systematic way to communicate changes. Breaking changes are discovered during integration or, worse, in production.
Version compatibility tracking: Determining which consumer version is compatible with which provider version becomes a manual spreadsheet exercise that breaks down as services scale.
These problems compound as the number of services grows. Contract testing addresses them by establishing a clear, testable contract between services.
What Is Consumer-Driven Contract Testing?
Consumer-driven contract testing inverts the typical testing model. Instead of the provider defining what the API should do and testing against that definition, consumers define what they need from the API. The provider then verifies they can fulfill those requirements.
Here's how the workflow operates:
The consumer test defines expected interactions with the API. From this test, Pact generates a contract file (JSON) that describes the request, expected response structure, and matching rules. The provider fetches this contract and verifies their implementation can satisfy it.
This approach differs from other testing strategies:
- E2E tests: Test entire system integration, slow and brittle
- Unit tests with mocks: Test in isolation, fast but subject to mock drift
- Contract tests: Test service boundaries, fast and accurate for API compatibility
Contract testing doesn't replace these approaches but complements them by focusing specifically on API compatibility.
Setting Up Pact for TypeScript Projects
Install Pact as a development dependency:
Organize your project structure to separate consumer and provider tests:
Add npm scripts to run consumer tests, provider verification, and publish contracts:
Writing Consumer Contract Tests
Consumer tests define the contract by specifying expected API interactions. Here's a basic example:
Key principles for writing effective consumer tests:
1. Exercise real consumer code: Don't just make HTTP requests with a library like axios. Use your actual HTTP client implementation:
2. Use flexible matching: Specify types, not exact values. This prevents brittle tests that break when irrelevant data changes:
3. Test only what you use: Don't include API fields in the contract that your consumer doesn't actually need. This allows the provider to evolve unused parts of the API without breaking your contract.
4. Avoid testing provider bugs: Don't specify exact error messages or internal provider behavior:
Understanding Pact Matchers
Matchers define flexible contract rules that verify type and structure rather than exact values:
Matcher usage guidelines:
like(): Most common - matches type and structureeachLike(): For arrays where all elements match a patternatLeastLike(): When minimum array length mattersregex(): Use sparingly for formats like SKU, email patterns- Avoid exact value matching unless the value is semantically meaningful (like API version numbers)
Testing POST/PUT Requests
Testing request bodies ensures consumers send correct data:
The contract verifies both the request format the consumer sends and the response structure the consumer expects.
Provider Verification with State Handlers
Provider verification tests that the actual API implementation can fulfill consumer contracts. This requires setting up appropriate test data for each provider state:
State handlers are critical for isolated, repeatable tests. Each provider state sets up exactly the data needed for that test scenario, then cleans up afterward.
For parallel test execution, avoid shared test data:
Pact Broker Integration
Pact Broker is the central contract repository that enables the full consumer-driven workflow. You can self-host the open-source version or use PactFlow (hosted SaaS):
Self-hosted with Docker:
Publishing contracts from consumer:
Publishing verification results from provider:
This happens automatically when publishVerificationResult: true is set in the Verifier options.
Using can-i-deploy:
The can-i-deploy feature checks if a consumer version is compatible with deployed provider versions:
This gate in your deployment pipeline prevents deploying incompatible service versions.
CI/CD Integration
Contract testing only provides value when integrated into your deployment pipeline. Here's a GitHub Actions example:
The pipeline flow:
- Consumer tests run and generate pact files
- Pacts are published to the broker (main branch only)
- Provider tests fetch pacts from the broker and verify
- Verification results are published
- can-i-deploy checks if deployment is safe
- Deployment proceeds only if contracts are satisfied
Handling Breaking Changes
Breaking changes require a migration strategy. Here's how to handle a field type change:
Scenario: Provider needs to change phone from required to optional.
If the provider makes this change directly, consumer contract verification fails. The migration pattern:
- Provider adds new optional field alongside old required field
- Consumers gradually update to use the new field
- After all consumers migrate, provider removes the old field
Using version selectors to test against the right contracts:
The enablePending feature allows new consumer contracts to be verified without failing the provider build. This prevents chicken-and-egg deployment issues where neither consumer nor provider can deploy first.
Common Pitfalls and Solutions
Pitfall 1: Over-Specified Contracts
Testing every field in an API response makes contracts brittle. The provider can't evolve unused fields without breaking consumer tests.
Solution: Only specify fields the consumer actually uses. Review contracts periodically to remove unused fields.
Pitfall 2: Using Pact as a General Stub
Using the Pact mock provider for integration tests without running verification defeats the purpose.
Solution: Use Pact exclusively for contract testing. For general stubbing, use tools like MSW or WireMock.
Pitfall 3: Random Test Data
Using new Date().toISOString() or uuid() in contracts causes them to change on every test run.
Solution: Use static test data or matchers:
Pitfall 4: Not Testing Real Consumer Code
Writing Pact tests that don't exercise your actual HTTP client misses client bugs.
Solution: Always use your production client code in contract tests. The test should call the same methods your application calls.
Pitfall 5: Broker Reliability
Pact Broker downtime blocks deployments if can-i-deploy checks fail.
Solution: Use a hosted solution like PactFlow for reliability, or implement high availability for self-hosted brokers. Set up monitoring and have a fallback process for outages.
Contract Testing in the Testing Strategy
Contract testing fills a specific gap in the testing pyramid. It doesn't replace other testing types:
When to use contract testing:
- Services communicate over HTTP APIs
- Multiple teams own different services
- Need to ensure API compatibility
- Want faster feedback than E2E tests
When NOT to use contract testing:
- Single team owns all services (use integration tests instead)
- UI-to-backend testing (use E2E tests)
- Business workflow testing (use E2E tests)
- Performance testing (use load tests)
Balanced testing approach:
Keep E2E tests for critical user journeys. Use contract tests to reduce E2E coverage of all API variations. Focus E2E tests on happy paths and critical errors. Use contract tests for edge cases and API variations.
Results and Practical Takeaways
Working with contract testing across several microservices projects revealed consistent patterns:
Integration test reduction: Contract testing reduced integration test runtime by 60-70%. Tests that previously took 15-20 minutes ran in 2-3 minutes.
Pre-deployment detection: Breaking changes were caught during development rather than in staging or production. The can-i-deploy gate prevented approximately 3-5 breaking deployments per month.
Team coordination improvement: Pact Broker provided visibility into which consumers depended on which provider APIs. This reduced ad-hoc communication overhead.
Learning curve: Teams needed 2-4 weeks to understand consumer-driven contracts and establish workflows. Initial friction came from understanding state handlers and matcher usage.
Key lessons learned:
1. Start small: Don't try to add contract testing to all services at once. Start with one critical integration, measure impact, then expand.
2. Matchers are essential: Using like() and eachLike() for flexible matching prevents brittle tests. Over-specified contracts were the most common initial mistake.
3. State handlers need care: Proper provider state setup is complex. Invest time in isolated test data management to avoid flaky tests.
4. Team communication matters: Contract testing is a technical tool, but it requires collaboration. Use it as a conversation starter, not a replacement for communication.
5. CI/CD integration is required: Contract testing only works when integrated into deployment pipelines with can-i-deploy gates. Without the gate, contracts aren't enforced.
6. Don't replace all E2E tests: Contract tests verify API compatibility, not business workflows. We maintained E2E tests for critical user journeys.
7. Broker reliability matters: Pact Broker downtime initially blocked deployments until we implemented fallback processes and monitoring.
Contract testing proved most valuable in projects with 5+ services, multiple teams, and frequent API changes. For smaller projects with tight team coordination, the overhead outweighed the benefits.
The investment pays off when API changes become frequent and coordination overhead increases. Contract testing provides the most value in environments with distributed ownership and rapid deployment cycles.
References
- jestjs.io - Jest testing framework documentation.
- microservices.io - Microservices patterns catalog (Chris Richardson).
- typescriptlang.org - TypeScript Handbook and language reference.
- github.com - TypeScript project wiki (FAQ and design notes).
- docs.github.com - GitHub Actions documentation.
- developer.mozilla.org - MDN Web Docs (web platform reference).
- semver.org - Semantic Versioning specification.