Skip to main content
โšก Calmops

Contract Testing: Ensuring API Compatibility in Microservices

Introduction

In a microservices architecture, services communicate through APIs. When one service changes its contract, it can break dependent services. Contract testing provides a way to verify that APIs remain compatible without requiring full integration environments. This guide covers contract testing fundamentals, implementation patterns, and best practices for modern distributed systems.

Understanding Contract Testing

What is a Contract?

A contract defines the agreement between a service provider (API) and its consumers (clients). This includes:

  • Request Structure: Headers, query parameters, body schema
  • Response Structure: Status codes, headers, response body
  • Behavior: Expected interactions and edge cases

Why Contract Testing?

Traditional integration testing requires running all services together, which becomes impractical as systems grow. Contract testing offers:

  • Fast Feedback: Test independently without full environment
  • Early Detection: Find breaking changes before deployment
  • Isolation: Each team can work independently
  • Documentation: Contracts serve as living API documentation

Contract Testing vs Integration Testing

Aspect Integration Testing Contract Testing
Environment Full system required Isolated
Speed Slow Fast
Scope End-to-end Per-service
Dependencies All services running Mock/stub
Failure Impact System-wide Single service

Consumer-Driven Contracts

The CDC Pattern

Consumer-driven contracts (CDC) flip the traditional approach. Instead of providers defining their API, consumers define what they need:

  1. Consumer writes tests defining expected behavior
  2. Tests generate contracts describing requirements
  3. Provider verifies it meets all consumer contracts
  4. Shared contract becomes the API specification

Benefits of CDC

  • Consumers drive API evolution
  • Providers know exactly what’s being used
  • Early feedback on breaking changes
  • Reduced coupling between teams

Pact: Consumer-Driven Contract Testing

Setting Up Pact

// consumer/package.json
{
  "dependencies": {
    "@pact-foundation/pact": "^12.0.0",
    "@pact-foundation/pact-node": "^10.17.0"
  }
}

Writing Consumer Tests

// consumer/test/booking-service.spec.js
const { Pact } = require('@pact-foundation/pact');
const { like, eachLike, regex } = require('@pact-foundation/pact').Matchers;
const axios = require('axios');

describe('Booking Service Consumer', () => {
  const provider = new Pact({
    consumer: 'booking-frontend',
    provider: 'booking-api',
    port: 8080,
    log: path.resolve(process.cwd(), 'logs', 'pact.log'),
    dir: path.resolve(process.cwd(), 'pacts'),
    logLevel: 'INFO',
  });

  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());

  describe('when getting bookings', () => {
    beforeAll(() =>
      provider.addInteraction({
        state: 'there are bookings',
        uponReceiving: 'a request for all bookings',
        withRequest: {
          method: 'GET',
          path: '/api/bookings',
          headers: {
            'Accept': 'application/json',
            'Authorization': regex({
              generate: 'Bearer token123',
              matcher: '^Bearer .+'
            })
          }
        },
        willRespondWith: {
          status: 200,
          headers: {
            'Content-Type': regex({
              generate: 'application/json',
              matcher: '^application/json'
            })
          },
          body: eachLike({
            id: like('booking-123'),
            customerName: like('John Doe'),
            checkIn: like('2026-03-15'),
            checkOut: like('2026-03-20'),
            roomType: like('suite'),
            totalPrice: like(500.00),
            status: like('confirmed')
          })
        }
      })
    );

    it('should return a list of bookings', async () => {
      const response = await axios.get('http://localhost:8080/api/bookings', {
        headers: { 'Authorization': 'Bearer token123' }
      });

      expect(response.status).toBe(200);
      expect(Array.isArray(response.data)).toBe(true);
      expect(response.data[0]).toHaveProperty('id');
      expect(response.data[0]).toHaveProperty('customerName');
    });
  });
});

Publishing Contracts

// scripts/publish-contracts.js
const { Publisher } = require('@pact-foundation/pact-node');

const publisher = new Publisher({
  pactBroker: 'https://pact-broker.example.com',
  pactFilesOrDirs: ['./pacts'],
  consumerVersion: '1.0.0',
  tags: ['main', 'production']
});

publisher.publish()
  .then(() => console.log('Contracts published successfully'))
  .catch(err => console.error('Failed to publish:', err));

Provider Verification

Setting Up Provider Tests

// provider/test/booking-api.spec.js
const { Verifier } = require('@pact-foundation/pact-node');
const { bootstrap } = require('./test-helpers');

describe('Booking API Provider', () => {
  const verifier = new Verifier({
    provider: 'booking-api',
    providerBaseUrl: 'http://localhost:3000',
    pactBrokerUrl: 'https://pact-broker.example.com',
    consumerVersionSelectors: [
      { mainBranch: true },
      { deployedOrReleased: true }
    ],
    enablePending: true,
    publishVerificationResult: true,
    providerVersion: '1.2.3'
  });

  beforeAll(async () => {
    await bootstrap(); // Start the API
  });

  it('should validate the expectations of booking-frontend', async () => {
    const output = await verifier.verify();
    console.log('Verification result:', output);
  });
});

Handling Provider State

// provider/test/provider-states.js
const { stateHandler } = require('@pact-foundation/pact-node');

const providerStates = {
  'there are bookings': async () => {
    await db.bookings.insert([
      { id: 'booking-123', customerName: 'John Doe', ... },
      { id: 'booking-456', customerName: 'Jane Smith', ... }
    ]);
  },
  
  'there are no bookings': async () => {
    await db.bookings.delete({});
  },
  
  'booking exists': async () => {
    await db.bookings.insert({
      id: 'booking-123',
      customerName: 'John Doe',
      checkIn: '2026-03-15',
      checkOut: '2026-03-20',
      status: 'confirmed'
    });
  },
  
  'booking is cancelled': async () => {
    await db.bookings.update(
      { id: 'booking-123' },
      { $set: { status: 'cancelled' } }
    );
  }
};

// Register states with the verifier
module.exports = providerStates;

Spring Cloud Contract

Provider Side (Java)

// pom.xml
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-verifier</artifactId>
    <scope>test</scope>
</dependency>
// src/test/java/com/example/booking/ContractTest.java
package com.example.booking;

import org.junit.jupiter.api.Test;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.RestTemplate;

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;

@AutoConfigureStubRunner(
    ids = "com.example:booking-api:+:stubs:8080",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
public class ContractTest {

    @Test
    public void validate_booking_contract() {
        RestTemplate restTemplate = new RestTemplate();
        
        ResponseEntity<Booking> response = restTemplate.getForEntity(
            "http://localhost:8080/api/bookings/123",
            Booking.class
        );
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getId()).isEqualTo("123");
    }
}

Consumer Side (Java)

// src/test/java/com/example/booking/client/BookingClientContractTest.java
package com.example.booking.client;

import org.junit.jupiter.api.Test;
import org.springframework.cloud.contract.wiremock.WireMockSpring;
import com.github.tomakehurst.wiremock.client.WireMock;
import static com.github.tomakehurst.wiremock.client.WireMock.*;

public class BookingClientContractTest {

    @Test
    public void test_get_booking() {
        // Configure mock
        stubFor(get(urlEqualTo("/api/bookings/123"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "id": "123",
                        "customerName": "John Doe",
                        "checkIn": "2026-03-15",
                        "checkOut": "2026-03-20",
                        "totalPrice": 500.00,
                        "status": "confirmed"
                    }
                    """)
            ));

        // Test client
        BookingClient client = new BookingClient("http://localhost:8080");
        Booking booking = client.getBooking("123");
        
        assertThat(booking.getId()).isEqualTo("123");
        assertThat(booking.getCustomerName()).isEqualTo("John Doe");
    }
}

Contract Testing Best Practices

1. Write Meaningful Contracts

// GOOD: Specific, meaningful interactions
provider.addInteraction({
  state: 'customer has active booking',
  uponReceiving: 'request to cancel booking',
  withRequest: {
    method: 'POST',
    path: '/api/bookings/123/cancel',
    headers: { 'Authorization': 'Bearer valid-token' }
  },
  willRespondWith: {
    status: 200,
    body: {
      bookingId: '123',
      status: 'cancelled',
      refundAmount: like(450.00),
      refundDate: like('2026-03-09')
    }
  }
});

// BAD: Generic, vague contracts
provider.addInteraction({
  uponReceiving: 'some request',
  willRespondWith: { status: 200 }
});

2. Handle Pagination

// Consumer contract for paginated response
provider.addInteraction({
  state: 'there are multiple bookings',
  uponReceiving: 'request for paginated bookings',
  withRequest: {
    method: 'GET',
    path: '/api/bookings',
    queryParameters: {
      page: '0',
      size: '20'
    }
  },
  willRespondWith: {
    status: 200,
    body: {
      content: eachLike({
        id: like('booking-1'),
        customerName: like('John')
      }),
      page: like(0),
      size: like(20),
      totalElements: like(100),
      totalPages: like(5)
    }
  }
});

3. Version Contracts

const contractVersion = '2.0.0';

describe(`Booking API v${contractVersion}`, () => {
  const provider = new Pact({
    consumer: `booking-frontend-v${contractVersion}`,
    provider: `booking-api-v${contractVersion}`,
    // ... config
  });
  
  // Tests use versioned contracts
});

CI/CD Integration

GitHub Actions Workflow

# .github/workflows/contracts.yml
name: Contract Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  consumer-contracts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run consumer tests
        run: npm run test:pact
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
      
      - name: Publish contracts
        run: npm run publish:pacts

  provider-contracts:
    runs-on: ubuntu-latest
    needs: consumer-contracts
    steps:
      - uses: actions/checkout@v4
      
      - name: Build application
        run: ./mvnw clean package -DskipTests
      
      - name: Run provider verification
        run: ./mvnw verify
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

Troubleshooting

Common Issues

Issue Solution
Flaky tests Use provider states consistently
Missing interactions Check interaction order
Version conflicts Ensure compatible Pact versions
Timeout errors Increase timeout settings
Port conflicts Use dynamic port allocation

Debugging Tips

// Enable verbose logging
const provider = new Pact({
  log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  logLevel: 'DEBUG',
  // ... other config
});

// Or run with debug
// DEBUG=* npm test

Conclusion

Contract testing is essential for maintaining reliable microservices architectures. By implementing consumer-driven contracts with tools like Pact, teams can verify API compatibility independently and catch breaking changes early. The key is to start small, write meaningful contracts, and integrate them into your CI/CD pipeline.

Remember that contract testing complements but doesn’t replace integration testing. Use contract tests for rapid feedback and integration tests for end-to-end validation.

Resources

Comments