Skip to main content

Contract Testing

Contract testing ensures that services can communicate correctly by validating API contracts between providers and consumers, catching integration issues early without requiring end-to-end tests.

Overview

Contract testing validates that the API provider (backend service) honors the contract expected by the consumer (frontend, mobile app, or other service). This prevents breaking changes from reaching production and reduces the need for expensive end-to-end tests.

Contract tests work by defining an explicit contract - usually an OpenAPI specification or Pact contract file - that both provider and consumer agree upon. The consumer tests verify they can work with responses matching the contract. The provider tests verify their API produces responses conforming to the contract. Both sides test independently, decoupled in time and execution.

The problem contract testing solves: Without contract tests, you discover API incompatibilities late - during integration testing, E2E testing, or worst case, in production. A backend team might rename a field from transactionId to id, unaware that three frontend applications depend on transactionId. Contract tests catch this immediately: the provider's contract test fails because the response doesn't match the OpenAPI spec, blocking the change before it breaks consumers.

Why Contract Testing?

Traditional integration tests require running all services together, creating complex test environments and slow feedback cycles. Contract tests validate each service independently against a shared contract, enabling:

  • Independent deployments: Deploy provider and consumer separately without coordinating releases
  • Faster feedback: Catch breaking changes in seconds, not hours (no need to spin up dependent services)
  • Parallel development: Frontend and backend teams work independently against the contract
  • Regression prevention: Automated verification that API changes don't break existing consumers

For a microservices architecture with 10 services, full integration testing requires coordinating 10 deployments and managing complex test data across services. Contract testing validates each service's compliance independently, reducing test environment complexity from O(n²) to O(n).

Platform Applicability

Applies to: Spring Boot · Angular · React · React Native

Contract testing validates API contracts between backend services (Spring Boot) and frontend consumers (Angular, React, React Native). Native mobile platforms use contract testing when consuming backend APIs.


Core Principles

  • Consumer-Driven: Consumers define what they need from the API
  • Independent Validation: Test provider and consumer separately
  • Fail Fast: Catch breaking changes before deployment
  • Schema-First: Define OpenAPI specs as the source of truth
  • CI Integration: Validate contracts in every pipeline run

OpenAPI Contract Validation

Defining OpenAPI Specifications

Create OpenAPI 3.x specifications as the contract:

# src/main/resources/api/payment-api.yaml
openapi: 3.0.3
info:
title: Payment API
version: 1.0.0
description: Payment processing API for banking applications

servers:
- url: https://api.bank.com/v1
description: Production server
- url: https://api-staging.bank.com/v1
description: Staging server

paths:
/payments:
post:
summary: Create a new payment
operationId: createPayment
tags:
- payments
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentRequest'
responses:
'201':
description: Payment created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentResponse'
headers:
Location:
schema:
type: string
description: URL of the created payment
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'422':
description: Unprocessable entity
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationErrorResponse'

/payments/{paymentId}:
get:
summary: Get payment by ID
operationId: getPayment
tags:
- payments
parameters:
- name: paymentId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Payment found
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentResponse'
'404':
description: Payment not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

components:
schemas:
PaymentRequest:
type: object
required:
- amount
- currency
- recipient
properties:
amount:
type: number
format: decimal
minimum: 0.01
example: 100.00
description: Payment amount
currency:
type: string
enum: [USD, EUR, GBP]
example: USD
description: Payment currency
recipient:
type: string
minLength: 1
maxLength: 255
example: John Doe
description: Payment recipient name
reference:
type: string
maxLength: 500
example: Invoice #12345
description: Optional payment reference

PaymentResponse:
type: object
required:
- transactionId
- amount
- currency
- status
- createdAt
properties:
transactionId:
type: string
format: uuid
example: 123e4567-e89b-12d3-a456-426614174000
amount:
type: number
format: decimal
example: 100.00
currency:
type: string
example: USD
recipient:
type: string
example: John Doe
status:
type: string
enum: [PENDING, COMPLETED, FAILED, CANCELLED]
example: COMPLETED
createdAt:
type: string
format: date-time
example: 2025-01-15T10:30:00Z

ErrorResponse:
type: object
required:
- error
- message
properties:
error:
type: string
example: Payment not found
message:
type: string
example: No payment found with ID 123e4567-e89b-12d3-a456-426614174000
timestamp:
type: string
format: date-time

ValidationErrorResponse:
type: object
required:
- errors
properties:
errors:
type: array
items:
$ref: '#/components/schemas/ValidationError'

ValidationError:
type: object
required:
- field
- message
properties:
field:
type: string
example: amount
message:
type: string
example: Amount must be positive

Backend: Validating Implementation Against OpenAPI

Spring Boot applications validate their API implementation against OpenAPI specifications using the swagger-request-validator library. This ensures the actual API responses match the documented contract.

// build.gradle
dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
testImplementation 'io.rest-assured:rest-assured:5.5.0'
testImplementation 'io.rest-assured:spring-mock-mvc:5.5.0'
testImplementation 'com.atlassian.oai:swagger-request-validator-restassured:2.41.0'
}

These dependencies provide OpenAPI documentation generation (springdoc-openapi), HTTP testing with a fluent API (rest-assured), Spring MockMvc integration for in-memory testing without starting a server (spring-mock-mvc), and the validator that checks responses against OpenAPI specs (swagger-request-validator).

// Contract validation test
import com.atlassian.oai.validator.restassured.OpenApiValidationFilter;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@SpringBootTest
class PaymentApiContractTest {

@Autowired
private WebApplicationContext context;

private OpenApiValidationFilter validationFilter;

@BeforeEach
void setUp() {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
RestAssuredMockMvc.mockMvc(mockMvc);

// Load OpenAPI spec
validationFilter = new OpenApiValidationFilter("api/payment-api.yaml");
}

@Test
void createPayment_shouldMatchOpenApiContract() {
PaymentRequest request = new PaymentRequest(
new BigDecimal("100.00"),
"USD",
"John Doe"
);

RestAssuredMockMvc
.given()
.filter(validationFilter) // Validate against OpenAPI spec
.contentType("application/json")
.body(request)
.when()
.post("/api/payments")
.then()
.statusCode(201) // OpenAPI spec validation passes
.header("Location", notNullValue());
}

@Test
void createPayment_withInvalidAmount_shouldReturn400() {
PaymentRequest request = new PaymentRequest(
new BigDecimal("-100.00"), // Invalid per OpenAPI spec
"USD",
"John Doe"
);

RestAssuredMockMvc
.given()
.filter(validationFilter)
.contentType("application/json")
.body(request)
.when()
.post("/api/payments")
.then()
.statusCode(400); // Matches OpenAPI error response
}

@Test
void getPayment_shouldMatchOpenApiContract() {
// Arrange: Create payment first
UUID paymentId = createTestPayment();

// Act & Assert
RestAssuredMockMvc
.given()
.filter(validationFilter)
.when()
.get("/api/payments/{paymentId}", paymentId)
.then()
.statusCode(200) // OpenAPI spec validation passes
.body("transactionId", equalTo(paymentId.toString()))
.body("status", in(Arrays.asList("PENDING", "COMPLETED", "FAILED", "CANCELLED")));
}
}

The OpenApiValidationFilter intercepts each request/response and validates them against the OpenAPI specification. If the response structure doesn't match the spec (wrong field types, missing required fields, invalid status codes), the test fails immediately with details about the mismatch. This catches contract violations automatically without manual assertions for every field.

Frontend: Generating TypeScript Client from OpenAPI

OpenAPI Generator creates TypeScript interfaces and API clients from the OpenAPI specification, ensuring type safety between frontend and backend.

# Install OpenAPI Generator
npm install --save-dev @openapitools/openapi-generator-cli

# Generate TypeScript client
npx openapi-generator-cli generate \
-i src/api/payment-api.yaml \
-g typescript-fetch \
-o src/generated/api \
--additional-properties=typescriptThreePlus=true,supportsES6=true

The generator creates TypeScript interfaces matching the OpenAPI schemas, enums for constrained values, and API client classes with type-safe methods. Changes to the OpenAPI spec automatically update these types, causing TypeScript compilation errors if frontend code uses outdated structures.

Generated TypeScript types:

// src/generated/api/models/PaymentRequest.ts
export interface PaymentRequest {
amount: number;
currency: PaymentRequestCurrencyEnum;
recipient: string;
reference?: string;
}

export enum PaymentRequestCurrencyEnum {
USD = 'USD',
EUR = 'EUR',
GBP = 'GBP'
}

// src/generated/api/models/PaymentResponse.ts
export interface PaymentResponse {
transactionId: string;
amount: number;
currency: string;
recipient: string;
status: PaymentResponseStatusEnum;
createdAt: Date;
}

export enum PaymentResponseStatusEnum {
PENDING = 'PENDING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
CANCELLED = 'CANCELLED'
}

// src/generated/api/apis/PaymentsApi.ts
export class PaymentsApi extends BaseAPI {
async createPayment(
requestParameters: CreatePaymentRequest,
initOverrides?: RequestInit
): Promise<PaymentResponse> {
// Generated implementation
}

async getPayment(
requestParameters: GetPaymentRequest,
initOverrides?: RequestInit
): Promise<PaymentResponse> {
// Generated implementation
}
}

Using generated client:

// src/services/PaymentService.ts
import { PaymentsApi, PaymentRequest, PaymentResponse } from '../generated/api';

export class PaymentService {
private api: PaymentsApi;

constructor() {
this.api = new PaymentsApi({
basePath: process.env.REACT_APP_API_URL
});
}

async createPayment(request: PaymentRequest): Promise<PaymentResponse> {
// Type-safe API call using generated types
return this.api.createPayment({ paymentRequest: request });
}

async getPayment(paymentId: string): Promise<PaymentResponse> {
return this.api.getPayment({ paymentId });
}
}

TypeScript enforces that request conforms to PaymentRequest interface, catching errors at compile time. If the backend adds a required field to PaymentRequest, the frontend code won't compile until that field is provided. This shifts API compatibility errors from runtime (production failures) to compile time (developer catches immediately).


Consumer-Driven Contracts with Pact

Pact Overview

Pact enables consumer-driven contract testing where the consumer defines expectations and the provider validates them.

Consumer Side (Frontend/React)

Install the Pact library to define consumer expectations. Pact starts a mock server that simulates the provider's API, records the interactions as a contract file, and publishes it to the Pact Broker for provider verification.

npm install --save-dev @pact-foundation/pact
// tests/contract/PaymentContract.test.ts
import { Pact } from '@pact-foundation/pact';
import { PaymentService } from '../../src/services/PaymentService';
import path from 'path';

describe('Payment API Contract', () => {
const provider = new Pact({
consumer: 'PaymentWebApp',
provider: 'PaymentAPI',
port: 8080,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'info'
});

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

describe('Create Payment', () => {
it('should create a payment successfully', async () => {
// Define expected interaction
await provider.addInteraction({
state: 'payment can be created',
uponReceiving: 'a request to create a payment',
withRequest: {
method: 'POST',
path: '/api/payments',
headers: {
'Content-Type': 'application/json'
},
body: {
amount: 100.00,
currency: 'USD',
recipient: 'John Doe'
}
},
willRespondWith: {
status: 201,
headers: {
'Content-Type': 'application/json',
'Location': '/api/payments/123e4567-e89b-12d3-a456-426614174000'
},
body: {
transactionId: '123e4567-e89b-12d3-a456-426614174000',
amount: 100.00,
currency: 'USD',
recipient: 'John Doe',
status: 'COMPLETED',
createdAt: '2025-01-15T10:30:00Z'
}
}
});

// Execute test against Pact mock server
const paymentService = new PaymentService('http://localhost:8080');
const result = await paymentService.createPayment({
amount: 100.00,
currency: 'USD',
recipient: 'John Doe'
});

expect(result.transactionId).toBe('123e4567-e89b-12d3-a456-426614174000');
expect(result.status).toBe('COMPLETED');
});
});

describe('Get Payment', () => {
it('should retrieve an existing payment', async () => {
const paymentId = '123e4567-e89b-12d3-a456-426614174000';

await provider.addInteraction({
state: 'payment exists',
uponReceiving: 'a request to get a payment',
withRequest: {
method: 'GET',
path: `/api/payments/${paymentId}`
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
transactionId: paymentId,
amount: 100.00,
currency: 'USD',
status: 'COMPLETED'
}
}
});

const paymentService = new PaymentService('http://localhost:8080');
const result = await paymentService.getPayment(paymentId);

expect(result.transactionId).toBe(paymentId);
});

it('should return 404 when payment not found', async () => {
const nonExistentId = '99999999-9999-9999-9999-999999999999';

await provider.addInteraction({
state: 'payment does not exist',
uponReceiving: 'a request for a non-existent payment',
withRequest: {
method: 'GET',
path: `/api/payments/${nonExistentId}`
},
willRespondWith: {
status: 404,
headers: {
'Content-Type': 'application/json'
},
body: {
error: 'Payment not found',
message: `No payment found with ID ${nonExistentId}`
}
}
});

const paymentService = new PaymentService('http://localhost:8080');

await expect(paymentService.getPayment(nonExistentId))
.rejects
.toThrow('Payment not found');
});
});
});

Provider Side (Backend/Spring Boot)

Add Pact provider libraries to verify the backend honors consumer contracts. The JUnit 5 integration enables contract verification tests, while the Spring module provides Spring Boot specific configuration and state management.

dependencies {
testImplementation 'au.com.dius.pact.provider:junit5:4.6.16' // JUnit 5 integration
testImplementation 'au.com.dius.pact.provider:spring:4.6.16' // Spring Boot support
}
import au.com.dius.pact.provider.junit5.HttpTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("PaymentAPI")
@PactBroker(host = "pact-broker.bank.com", port = "443", scheme = "https")
class PaymentApiProviderContractTest {

@LocalServerPort
private int port;

@Autowired
private PaymentRepository paymentRepository;

@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}

@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}

@State("payment can be created")
void paymentCanBeCreated() {
// Set up test state: ensure database is clean
paymentRepository.deleteAll();
}

@State("payment exists")
void paymentExists() {
// Set up test state: create payment in database
Payment payment = new Payment();
payment.setId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"));
payment.setAmount(new BigDecimal("100.00"));
payment.setCurrency("USD");
payment.setStatus(PaymentStatus.COMPLETED);
paymentRepository.save(payment);
}

@State("payment does not exist")
void paymentDoesNotExist() {
// Set up test state: ensure payment does not exist
paymentRepository.deleteAll();
}
}

CI/CD Integration with Pact Broker

# .gitlab-ci.yml

# Consumer: Publish contracts
publish-pact-contracts:
stage: test
image: node:22
script:
- npm ci
- npm run test:contract
- npx pact-broker publish ./pacts --consumer-app-version=$CI_COMMIT_SHA --broker-base-url=https://pact-broker.bank.com --broker-token=$PACT_BROKER_TOKEN
artifacts:
paths:
- pacts/
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# Provider: Verify contracts
verify-pact-contracts:
stage: test
image: eclipse-temurin:21-jdk
script:
- ./gradlew pactVerify -Ppact.verifier.publishResults=true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'

Best Practices

Version OpenAPI Specs

Store OpenAPI specs in version control:

src/
├── main/
│ └── resources/
│ └── api/
│ ├── payment-api-v1.yaml
│ └── payment-api-v2.yaml

Automate Client Generation

Add to package.json:

{
"scripts": {
"generate:api": "openapi-generator-cli generate -i src/api/payment-api.yaml -g typescript-fetch -o src/generated/api",
"prebuild": "npm run generate:api"
}
}

Use Pact Broker for Contract Management

Centralize contract storage and verification:

  • Store contracts in Pact Broker
  • Track consumer versions
  • Verify provider compatibility before deployment
  • Block deployments that break contracts

Test Error Responses

Validate error contracts:

it('should handle validation errors correctly', async () => {
await provider.addInteraction({
state: 'payment validation fails',
uponReceiving: 'a request with invalid amount',
withRequest: {
method: 'POST',
path: '/api/payments',
body: {
amount: -100, // Invalid
currency: 'USD',
recipient: 'John Doe'
}
},
willRespondWith: {
status: 400,
body: {
errors: [{
field: 'amount',
message: 'Amount must be positive'
}]
}
}
});

// Verify error handling
});

Further Reading

External Resources:


Summary

Key Takeaways:

  1. OpenAPI as Source of Truth: Define API contracts using OpenAPI 3.x specifications
  2. Backend Validation: Validate implementation against OpenAPI specs in tests
  3. Frontend Code Generation: Generate TypeScript clients from OpenAPI specs for type safety
  4. Consumer-Driven Contracts: Use Pact for consumer-provider contract testing
  5. Independent Testing: Test services independently without full integration
  6. Pact Broker: Centralize contract management and verification
  7. CI Integration: Validate contracts in every pipeline run to prevent breaking changes
  8. Version Contracts: Store OpenAPI specs in version control alongside code