Contract Testing for APIs: Ensure Microservices Compatibility

Master contract testing with PACT. Learn to define and test agreements between microservices to prevent integration failures and ensure compatibility.

The Microservices Integration Problem

In microservices architecture, services communicate with each other. But what happens when the user service changes its API contract and breaks the order service? Contract testing is your safety net.

Traditional integration tests are slow and fragile:

  • You need all services running
  • Tests are end-to-end and slow
  • Failures are hard to diagnose
  • Tests are coupled to implementation details

Contract testing solves this by defining explicit agreements between services.

What is a Contract?

A contract is an agreement about:

  • What endpoints exist
  • What data they accept
  • What data they return
  • What error conditions are possible

Example contract: "The Order Service calls the User Service's GET /users/:id endpoint and expects a 200 response with {id, name, email}."

Consumer vs Provider

In contract testing, we have two sides:

Consumer - The service that makes the API call (Order Service)
Provider - The service that provides the API (User Service)

Each writes tests from their perspective:

  • Consumer tests: "Does the User Service respond as expected?"
  • Provider tests: "Do my responses match what consumers expect?"

PACT Framework

PACT is the most popular contract testing framework. It has implementations for Node.js, Java, Python, Go, and more.

How PACT works:

  1. Consumer writes expectations for the provider
  2. Test runs against a mock provider
  3. Consumer generates a contract file
  4. Provider reads the contract and tests against it
  5. Both tests pass = compatible

Consumer Contract Example

import { pactWith } from 'jest-pact';

pactWith(
  { consumer: 'OrderService', provider: 'UserService' },
  (interaction) => {
    describe('GET /users/:id', () => {
      it('returns a user', () =>
        interaction
          .given('user 123 exists')
          .uponReceiving('a request for user 123')
          .withRequest({
            method: 'GET',
            path: '/users/123'
          })
          .willRespondWith({
            status: 200,
            body: {
              id: 123,
              name: 'John Doe',
              email: 'john@example.com'
            }
          })
          .executeTest((mockProvider) => {
            return userService.getUser(123);
          })
      );
    });
  }
);

CI/CD Integration

Contract testing shines in CI/CD:

  1. Consumer builds and generates contract
  2. Contract is published to a broker
  3. Provider downloads contract and tests
  4. If provider passes, deployment is safe
  5. If provider fails, it blocks deployment

Real-World Scenario

Imagine your Order Service calls User Service for user data. The contract specifies:

GET /users/123
Response: { id: 123, name: 'John', email: 'john@example.com' }

Now User Service wants to add a createdAt field. With contract testing:

  • User Service adds the field (backward compatible, contract still passes)
  • Order Service continues working without changes
  • No integration test needed

But if User Service removes the id field:

  • Provider test fails (breaks the contract)
  • Deployment is blocked
  • Team gets notified immediately

Conclusion

Contract testing prevents integration surprises in microservices. It's faster than integration tests, provides immediate feedback, and documents service agreements explicitly. If you have more than one service, contract testing should be part of your testing strategy.