Contract Testing for Microservices: Pact vs Spring Cloud Contract
Contract testing represents a critical paradigm shift in microservices testing strategy, moving beyond traditional end-to-end testing toward consumer-driven contract verification. This comprehensive guide explores the implementation of contract testing using Pact and Spring Cloud Contract, providing production-ready approaches for enterprise microservices architectures.
Understanding Contract Testing Fundamentals
Contract testing addresses the fundamental challenge of service integration testing in distributed systems. Unlike traditional integration tests that require all services to be running simultaneously, contract testing validates the compatibility between service consumers and providers through predefined contracts.
Core Principles of Contract Testing
Consumer-Driven Contracts
Consumer-driven contracts place the consumer’s needs at the center of the testing strategy. The consumer defines expectations about the provider’s behavior, creating a contract that the provider must satisfy:
# Example Pact Contract Structure
interactions:
- description: "Get user by ID"
request:
method: "GET"
path: "/users/123"
headers:
Accept: "application/json"
response:
status: 200
headers:
Content-Type: "application/json"
body:
id: 123
name: "John Doe"
email: "john.doe@example.com"
Contract Evolution and Versioning
Contract evolution requires careful management to prevent breaking changes:
// Go example of backward-compatible contract evolution
type UserV1 struct {
ID int `json:"id"`
Name string `json:"name"`
}
type UserV2 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // New field is optional
}
Pact Implementation Deep Dive
Pact provides a robust framework for consumer-driven contract testing across multiple programming languages. Let’s explore comprehensive implementation strategies.
Setting Up Pact for Go Microservices
Consumer Implementation
package consumer
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/pact-foundation/pact-go/dsl"
"github.com/pact-foundation/pact-go/types"
"github.com/stretchr/testify/assert"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type UserService struct {
BaseURL string
Client *http.Client
}
func (u *UserService) GetUser(id int) (*User, error) {
resp, err := u.Client.Get(fmt.Sprintf("%s/users/%d", u.BaseURL, id))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("user not found")
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
func TestUserServicePact(t *testing.T) {
pact := &dsl.Pact{
Consumer: "UserConsumer",
Provider: "UserProvider",
Host: "localhost",
Port: 8000,
LogDir: "./logs",
PactDir: "./pacts",
}
defer pact.Teardown()
t.Run("GetUser returns user when user exists", func(t *testing.T) {
pact.
AddInteraction().
Given("User 123 exists").
UponReceiving("A request for user 123").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.String("/users/123"),
Headers: dsl.MapMatcher{
"Accept": dsl.String("application/json"),
},
}).
WillRespondWith(dsl.Response{
Status: 200,
Headers: dsl.MapMatcher{
"Content-Type": dsl.String("application/json"),
},
Body: dsl.Match(User{
ID: 123,
Name: "John Doe",
Email: "john.doe@example.com",
}),
})
err := pact.Verify(func() error {
userService := &UserService{
BaseURL: fmt.Sprintf("http://localhost:%d", pact.Server.Port),
Client: &http.Client{},
}
user, err := userService.GetUser(123)
if err != nil {
return err
}
assert.Equal(t, 123, user.ID)
assert.Equal(t, "John Doe", user.Name)
assert.Equal(t, "john.doe@example.com", user.Email)
return nil
})
assert.NoError(t, err)
})
t.Run("GetUser returns error when user does not exist", func(t *testing.T) {
pact.
AddInteraction().
Given("User 999 does not exist").
UponReceiving("A request for user 999").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.String("/users/999"),
Headers: dsl.MapMatcher{
"Accept": dsl.String("application/json"),
},
}).
WillRespondWith(dsl.Response{
Status: 404,
Headers: dsl.MapMatcher{
"Content-Type": dsl.String("application/json"),
},
Body: dsl.Like(map[string]interface{}{
"error": "User not found",
}),
})
err := pact.Verify(func() error {
userService := &UserService{
BaseURL: fmt.Sprintf("http://localhost:%d", pact.Server.Port),
Client: &http.Client{},
}
_, err := userService.GetUser(999)
return err // We expect an error here
})
assert.Error(t, err)
})
}
Provider Verification
package provider
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/pact-foundation/pact-go/dsl"
"github.com/pact-foundation/pact-go/types"
"github.com/pact-foundation/pact-go/utils"
)
type UserRepository struct {
users map[int]*User
}
func NewUserRepository() *UserRepository {
return &UserRepository{
users: map[int]*User{
123: {ID: 123, Name: "John Doe", Email: "john.doe@example.com"},
456: {ID: 456, Name: "Jane Smith", Email: "jane.smith@example.com"},
},
}
}
func (r *UserRepository) GetUser(id int) (*User, error) {
user, exists := r.users[id]
if !exists {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
type UserHandler struct {
repo *UserRepository
}
func NewUserHandler(repo *UserRepository) *UserHandler {
return &UserHandler{repo: repo}
}
func (h *UserHandler) GetUser(c *gin.Context) {
id := c.Param("id")
userId := 0
fmt.Sscanf(id, "%d", &userId)
user, err := h.repo.GetUser(userId)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
func setupRouter(handler *UserHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/users/:id", handler.GetUser)
return router
}
func TestPactProvider(t *testing.T) {
repo := NewUserRepository()
handler := NewUserHandler(repo)
router := setupRouter(handler)
server := httptest.NewServer(router)
defer server.Close()
pact := dsl.Pact{
Consumer: "UserConsumer",
Provider: "UserProvider",
}
// State setup for provider verification
stateHandlers := types.StateHandlers{
"User 123 exists": func() error {
// Ensure user 123 exists in repository
repo.users[123] = &User{
ID: 123,
Name: "John Doe",
Email: "john.doe@example.com",
}
return nil
},
"User 999 does not exist": func() error {
// Ensure user 999 does not exist
delete(repo.users, 999)
return nil
},
}
// Verify pacts against provider
_, err := pact.VerifyProvider(t, types.VerifyRequest{
ProviderBaseURL: server.URL,
PactURLs: []string{filepath.Join("..", "pacts", "userconsumer-userprovider.json")},
StateHandlers: stateHandlers,
BrokerURL: os.Getenv("PACT_BROKER_URL"),
PublishVerificationResults: true,
ProviderVersion: "1.0.0",
})
if err != nil {
t.Fatalf("Provider verification failed: %v", err)
}
}
Advanced Pact Features
Message Contracts
For asynchronous communication patterns:
package messaging
import (
"encoding/json"
"testing"
"github.com/pact-foundation/pact-go/dsl"
"github.com/stretchr/testify/assert"
)
type UserCreatedEvent struct {
UserID int `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Timestamp string `json:"timestamp"`
}
func TestUserCreatedMessageContract(t *testing.T) {
pact := &dsl.Pact{
Consumer: "UserEventConsumer",
Provider: "UserEventProvider",
}
message := pact.AddMessage()
message.
Given("A user is created").
ExpectsToReceive("A user created event").
WithContent(dsl.Match(UserCreatedEvent{
UserID: dsl.Like(123),
Name: dsl.Like("John Doe"),
Email: dsl.Like("john.doe@example.com"),
Timestamp: dsl.Like("2025-05-05T10:00:00Z"),
}))
err := message.Verify(func(messageBytes []byte) error {
var event UserCreatedEvent
if err := json.Unmarshal(messageBytes, &event); err != nil {
return err
}
// Process the event
assert.NotZero(t, event.UserID)
assert.NotEmpty(t, event.Name)
assert.NotEmpty(t, event.Email)
assert.NotEmpty(t, event.Timestamp)
return nil
})
assert.NoError(t, err)
}
Dynamic Provider States
type ProviderStateManager struct {
repo *UserRepository
}
func (p *ProviderStateManager) SetupState(state string, params map[string]interface{}) error {
switch state {
case "User exists with specific data":
userID := int(params["userId"].(float64))
name := params["name"].(string)
email := params["email"].(string)
p.repo.users[userID] = &User{
ID: userID,
Name: name,
Email: email,
}
return nil
case "Database is empty":
p.repo.users = make(map[int]*User)
return nil
default:
return fmt.Errorf("unknown state: %s", state)
}
}
Spring Cloud Contract Implementation
Spring Cloud Contract provides a JVM-native approach to contract testing with excellent integration into the Spring ecosystem.
Consumer Implementation with Spring Cloud Contract
Gradle Configuration
// build.gradle
plugins {
id 'org.springframework.cloud.contract' version '3.1.0'
id 'org.springframework.boot' version '2.7.0'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
}
contracts {
testFramework = 'JUNIT5'
packageWithBaseClasses = 'com.example.contracts'
}
Consumer Test Implementation
package com.example.consumer;
import com.github.tomakehurst.wiremock.WireMockServer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.test.context.TestPropertySource;
import org.springframework.beans.factory.annotation.Autowired;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(
ids = "com.example:user-service:+:stubs:8080",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
@TestPropertySource(properties = {
"user.service.url=http://localhost:8080"
})
class UserServiceContractTest {
@Autowired
private UserServiceClient userServiceClient;
@Test
void should_return_user_when_user_exists() {
// Given & When
User user = userServiceClient.getUser(123);
// Then
assertThat(user).isNotNull();
assertThat(user.getId()).isEqualTo(123);
assertThat(user.getName()).isEqualTo("John Doe");
assertThat(user.getEmail()).isEqualTo("john.doe@example.com");
}
@Test
void should_throw_exception_when_user_not_found() {
// Given & When & Then
assertThatThrownBy(() -> userServiceClient.getUser(999))
.isInstanceOf(UserNotFoundException.class)
.hasMessageContaining("User not found");
}
}
// UserServiceClient implementation
@Component
public class UserServiceClient {
@Value("${user.service.url}")
private String userServiceUrl;
private final RestTemplate restTemplate;
public UserServiceClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public User getUser(int userId) {
try {
ResponseEntity<User> response = restTemplate.getForEntity(
userServiceUrl + "/users/" + userId,
User.class
);
return response.getBody();
} catch (HttpClientErrorException.NotFound e) {
throw new UserNotFoundException("User not found: " + userId);
}
}
}
Provider Contract Definition
Groovy DSL Contract
// src/test/resources/contracts/user/should_return_user_when_exists.groovy
package contracts.user
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "Should return user when user exists"
request {
method GET()
url "/users/123"
headers {
accept("application/json")
}
}
response {
status OK()
headers {
contentType("application/json")
}
body([
id: 123,
name: "John Doe",
email: "john.doe@example.com"
])
}
}
// src/test/resources/contracts/user/should_return_error_when_user_not_found.groovy
package contracts.user
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "Should return error when user not found"
request {
method GET()
url "/users/999"
headers {
accept("application/json")
}
}
response {
status NOT_FOUND()
headers {
contentType("application/json")
}
body([
error: "User not found"
])
}
}
Provider Base Test Class
package com.example.contracts;
import com.example.provider.UserController;
import com.example.provider.UserService;
import com.example.provider.User;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@SpringBootTest(classes = {UserController.class})
@TestPropertySource(properties = "debug=true")
public abstract class UserContractTestBase {
@MockBean
private UserService userService;
@BeforeEach
void setup() {
RestAssuredMockMvc.standaloneSetup(new UserController(userService));
// Setup mock behaviors for contract verification
when(userService.getUserById(eq(123)))
.thenReturn(new User(123, "John Doe", "john.doe@example.com"));
when(userService.getUserById(eq(999)))
.thenThrow(new UserNotFoundException("User not found"));
}
}
Advanced Spring Cloud Contract Features
Dynamic Contract Generation
package com.example.contracts;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.cloud.contract.spec.internal.DslProperty;
import java.util.Arrays;
import java.util.Collection;
public class DynamicUserContracts {
public static Collection<Contract> createUserContracts() {
return Arrays.asList(
createGetUserContract(123, "John Doe", "john.doe@example.com"),
createGetUserContract(456, "Jane Smith", "jane.smith@example.com")
);
}
private static Contract createGetUserContract(int userId, String name, String email) {
return Contract.make(c -> {
c.description("Should return user " + userId);
c.request(r -> {
r.method("GET");
r.url("/users/" + userId);
r.headers(h -> h.accept("application/json"));
});
c.response(r -> {
r.status(200);
r.headers(h -> h.contentType("application/json"));
r.body(new DslProperty<>(java.util.Map.of(
"id", userId,
"name", name,
"email", email
)));
});
});
}
}
Custom Matchers
package contracts.user
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "Should return paginated users"
request {
method GET()
url "/users"
queryParameters {
parameter("page", "0")
parameter("size", "10")
}
}
response {
status OK()
headers {
contentType("application/json")
}
body([
content: [
[
id: anyPositiveInt(),
name: anyNonEmptyString(),
email: regex(email())
]
],
totalElements: anyPositiveInt(),
totalPages: anyPositiveInt(),
size: 10,
number: 0
])
}
}
CI/CD Integration Patterns
Effective contract testing requires seamless integration into continuous integration and deployment pipelines.
GitHub Actions Workflow
# .github/workflows/contract-testing.yml
name: Contract Testing Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
consumer-contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.21
- name: Run Consumer Contract Tests
run: |
go mod download
go test ./consumer/... -v
- name: Publish Pacts to Broker
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
docker run --rm \
-v ${PWD}/pacts:/pacts \
pactfoundation/pact-cli:latest \
publish /pacts \
--consumer-app-version ${{ github.sha }} \
--branch ${{ github.ref_name }} \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN
provider-contract-verification:
runs-on: ubuntu-latest
needs: consumer-contract-tests
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.21
- name: Start Provider Service
run: |
go build -o provider ./provider/cmd/main.go
./provider &
sleep 10
- name: Verify Provider Contracts
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
docker run --rm \
--network host \
pactfoundation/pact-cli:latest \
verify \
--provider-base-url http://localhost:8080 \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN \
--provider UserProvider \
--provider-app-version ${{ github.sha }} \
--publish-verification-results
can-i-deploy:
runs-on: ubuntu-latest
needs: [consumer-contract-tests, provider-contract-verification]
steps:
- name: Can I Deploy Check
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
docker run --rm \
pactfoundation/pact-cli:latest \
can-i-deploy \
--pacticipant UserConsumer \
--version ${{ github.sha }} \
--to production \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN
Jenkins Pipeline
// Jenkinsfile
pipeline {
agent any
environment {
PACT_BROKER_URL = credentials('pact-broker-url')
PACT_BROKER_TOKEN = credentials('pact-broker-token')
GO_VERSION = '1.21'
}
stages {
stage('Setup') {
steps {
script {
// Install Go
sh """
wget -O go.tar.gz https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz
tar -C /usr/local -xzf go.tar.gz
export PATH=\$PATH:/usr/local/go/bin
"""
}
}
}
stage('Consumer Tests') {
steps {
script {
sh """
export PATH=\$PATH:/usr/local/go/bin
go mod download
go test ./consumer/... -v
"""
}
}
post {
always {
publishHTML([
allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'logs',
reportFiles: '*.log',
reportName: 'Pact Logs'
])
}
}
}
stage('Publish Contracts') {
steps {
script {
sh """
docker run --rm \
-v \${PWD}/pacts:/pacts \
pactfoundation/pact-cli:latest \
publish /pacts \
--consumer-app-version \${BUILD_NUMBER} \
--branch \${BRANCH_NAME} \
--broker-base-url \${PACT_BROKER_URL} \
--broker-token \${PACT_BROKER_TOKEN}
"""
}
}
}
stage('Provider Verification') {
parallel {
stage('User Service') {
steps {
script {
sh """
export PATH=\$PATH:/usr/local/go/bin
go build -o user-provider ./provider/user/cmd/main.go
./user-provider &
sleep 10
docker run --rm \
--network host \
pactfoundation/pact-cli:latest \
verify \
--provider-base-url http://localhost:8080 \
--broker-base-url \${PACT_BROKER_URL} \
--broker-token \${PACT_BROKER_TOKEN} \
--provider UserProvider \
--provider-app-version \${BUILD_NUMBER} \
--publish-verification-results
"""
}
}
}
stage('Order Service') {
steps {
script {
sh """
export PATH=\$PATH:/usr/local/go/bin
go build -o order-provider ./provider/order/cmd/main.go
./order-provider &
sleep 10
docker run --rm \
--network host \
pactfoundation/pact-cli:latest \
verify \
--provider-base-url http://localhost:8081 \
--broker-base-url \${PACT_BROKER_URL} \
--broker-token \${PACT_BROKER_TOKEN} \
--provider OrderProvider \
--provider-app-version \${BUILD_NUMBER} \
--publish-verification-results
"""
}
}
}
}
}
stage('Deployment Gate') {
steps {
script {
def canDeploy = sh(
script: """
docker run --rm \
pactfoundation/pact-cli:latest \
can-i-deploy \
--pacticipant UserConsumer \
--version \${BUILD_NUMBER} \
--to production \
--broker-base-url \${PACT_BROKER_URL} \
--broker-token \${PACT_BROKER_TOKEN}
""",
returnStatus: true
)
if (canDeploy != 0) {
error("Contract verification failed. Cannot deploy to production.")
}
}
}
}
}
post {
always {
// Clean up running services
sh """
pkill -f user-provider || true
pkill -f order-provider || true
"""
}
}
}
Performance Impact Analysis
Understanding the performance implications of contract testing helps optimize testing strategies without compromising delivery speed.
Benchmarking Contract Test Execution
package benchmarks
import (
"testing"
"time"
"github.com/pact-foundation/pact-go/dsl"
)
func BenchmarkPactConsumerTest(b *testing.B) {
pact := &dsl.Pact{
Consumer: "BenchmarkConsumer",
Provider: "BenchmarkProvider",
Host: "localhost",
Port: 8000,
}
defer pact.Teardown()
// Setup interaction
pact.AddInteraction().
Given("User exists").
UponReceiving("A request for user").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.String("/users/123"),
}).
WillRespondWith(dsl.Response{
Status: 200,
Body: dsl.Like(map[string]interface{}{"id": 123}),
})
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := pact.Verify(func() error {
// Simulate API call
time.Sleep(time.Millisecond * 10)
return nil
})
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkSpringCloudContractTest(b *testing.B) {
// Benchmark Spring Cloud Contract execution
// Implementation would depend on specific Spring setup
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Simulate contract test execution
time.Sleep(time.Millisecond * 15)
}
}
Performance Optimization Strategies
Parallel Test Execution
package parallel
import (
"sync"
"testing"
"github.com/pact-foundation/pact-go/dsl"
)
func TestParallelContractExecution(t *testing.T) {
testCases := []struct {
name string
userId int
expected int
}{
{"User 1", 1, 1},
{"User 2", 2, 2},
{"User 3", 3, 3},
}
var wg sync.WaitGroup
semaphore := make(chan struct{}, 3) // Limit concurrent tests
for _, tc := range testCases {
wg.Add(1)
go func(testCase struct {
name string
userId int
expected int
}) {
defer wg.Done()
semaphore <- struct{}{} // Acquire semaphore
defer func() { <-semaphore }() // Release semaphore
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
pact := &dsl.Pact{
Consumer: "ParallelConsumer",
Provider: "ParallelProvider",
Host: "localhost",
Port: 8000 + testCase.userId, // Use different ports
}
defer pact.Teardown()
// Run contract test
pact.AddInteraction().
Given("User exists").
UponReceiving("A request for user").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.String(fmt.Sprintf("/users/%d", testCase.userId)),
}).
WillRespondWith(dsl.Response{
Status: 200,
Body: dsl.Like(map[string]interface{}{"id": testCase.expected}),
})
err := pact.Verify(func() error {
// Test implementation
return nil
})
if err != nil {
t.Error(err)
}
})
}(tc)
}
wg.Wait()
}
Resource Pooling for Provider Tests
package provider
import (
"sync"
"testing"
"net/http/httptest"
)
type ProviderPool struct {
servers []*httptest.Server
mutex sync.Mutex
index int
}
func NewProviderPool(size int) *ProviderPool {
pool := &ProviderPool{
servers: make([]*httptest.Server, size),
}
for i := 0; i < size; i++ {
pool.servers[i] = httptest.NewServer(createHandler())
}
return pool
}
func (p *ProviderPool) GetServer() *httptest.Server {
p.mutex.Lock()
defer p.mutex.Unlock()
server := p.servers[p.index]
p.index = (p.index + 1) % len(p.servers)
return server
}
func (p *ProviderPool) Cleanup() {
for _, server := range p.servers {
server.Close()
}
}
// Usage in tests
var providerPool *ProviderPool
func init() {
providerPool = NewProviderPool(5)
}
func TestWithPooledProvider(t *testing.T) {
server := providerPool.GetServer()
// Use server for contract verification
// No need to start/stop server for each test
}
Contract Evolution Strategies
Managing contract evolution while maintaining backward compatibility requires careful planning and implementation.
Semantic Versioning for Contracts
package versioning
import (
"fmt"
"strconv"
"strings"
)
type ContractVersion struct {
Major int
Minor int
Patch int
}
func (v ContractVersion) String() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
func (v ContractVersion) IsCompatibleWith(other ContractVersion) bool {
// Major version must match for compatibility
if v.Major != other.Major {
return false
}
// Consumer can handle newer minor/patch versions
return v.Minor >= other.Minor ||
(v.Minor == other.Minor && v.Patch >= other.Patch)
}
func ParseVersion(version string) (ContractVersion, error) {
parts := strings.Split(version, ".")
if len(parts) != 3 {
return ContractVersion{}, fmt.Errorf("invalid version format: %s", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return ContractVersion{}, err
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return ContractVersion{}, err
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return ContractVersion{}, err
}
return ContractVersion{
Major: major,
Minor: minor,
Patch: patch,
}, nil
}
// Contract evolution validation
type ContractEvolutionValidator struct {
previousContracts map[string]ContractVersion
}
func (v *ContractEvolutionValidator) ValidateEvolution(
contractName string,
newVersion ContractVersion,
changes []ContractChange,
) error {
previousVersion, exists := v.previousContracts[contractName]
if !exists {
// New contract, no validation needed
return nil
}
// Validate version progression
if newVersion.Major < previousVersion.Major ||
(newVersion.Major == previousVersion.Major && newVersion.Minor < previousVersion.Minor) ||
(newVersion.Major == previousVersion.Major &&
newVersion.Minor == previousVersion.Minor &&
newVersion.Patch <= previousVersion.Patch) {
return fmt.Errorf("version %s is not greater than previous version %s",
newVersion, previousVersion)
}
// Validate changes
for _, change := range changes {
if err := v.validateChange(change, previousVersion, newVersion); err != nil {
return err
}
}
return nil
}
type ContractChange struct {
Type ChangeType
Field string
Description string
}
type ChangeType int
const (
AddedField ChangeType = iota
RemovedField
ModifiedField
AddedEndpoint
RemovedEndpoint
ModifiedEndpoint
)
func (v *ContractEvolutionValidator) validateChange(
change ContractChange,
previousVersion,
newVersion ContractVersion,
) error {
switch change.Type {
case RemovedField, RemovedEndpoint:
if newVersion.Major == previousVersion.Major {
return fmt.Errorf("breaking change %s requires major version bump",
change.Description)
}
case ModifiedField, ModifiedEndpoint:
// Check if modification is backward compatible
if !v.isBackwardCompatible(change) && newVersion.Major == previousVersion.Major {
return fmt.Errorf("incompatible change %s requires major version bump",
change.Description)
}
}
return nil
}
func (v *ContractEvolutionValidator) isBackwardCompatible(change ContractChange) bool {
// Implementation would check specific compatibility rules
// For example, adding optional fields is compatible
// Changing field types is not compatible
return strings.Contains(change.Description, "optional")
}
Backward Compatibility Testing
package compatibility
import (
"testing"
"fmt"
"github.com/pact-foundation/pact-go/dsl"
)
type CompatibilityTester struct {
versions []string
}
func NewCompatibilityTester(versions []string) *CompatibilityTester {
return &CompatibilityTester{versions: versions}
}
func (c *CompatibilityTester) TestBackwardCompatibility(t *testing.T) {
for i := 0; i < len(c.versions)-1; i++ {
currentVersion := c.versions[i]
nextVersion := c.versions[i+1]
t.Run(fmt.Sprintf("Compatibility_%s_to_%s", currentVersion, nextVersion), func(t *testing.T) {
c.testVersionCompatibility(t, currentVersion, nextVersion)
})
}
}
func (c *CompatibilityTester) testVersionCompatibility(t *testing.T, oldVersion, newVersion string) {
// Test that old consumer can work with new provider
pact := &dsl.Pact{
Consumer: fmt.Sprintf("Consumer_%s", oldVersion),
Provider: fmt.Sprintf("Provider_%s", newVersion),
Host: "localhost",
Port: 8000,
}
defer pact.Teardown()
// Load old consumer contract
oldContract := c.loadContract(oldVersion)
// Verify against new provider
for _, interaction := range oldContract.Interactions {
pact.AddInteraction().
Given(interaction.ProviderState).
UponReceiving(interaction.Description).
WithRequest(interaction.Request).
WillRespondWith(interaction.Response)
}
err := pact.Verify(func() error {
// Run consumer code using old contract expectations
return c.runConsumerCode(oldVersion)
})
if err != nil {
t.Errorf("Backward compatibility broken between %s and %s: %v",
oldVersion, newVersion, err)
}
}
func (c *CompatibilityTester) loadContract(version string) Contract {
// Load contract definition for specific version
// Implementation would read from files or contract broker
return Contract{}
}
func (c *CompatibilityTester) runConsumerCode(version string) error {
// Run consumer code for specific version
// Implementation would execute version-specific consumer logic
return nil
}
type Contract struct {
Interactions []Interaction
}
type Interaction struct {
Description string
ProviderState string
Request dsl.Request
Response dsl.Response
}
Contract Migration Automation
package migration
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
)
type ContractMigrator struct {
sourceDir string
targetDir string
}
func NewContractMigrator(sourceDir, targetDir string) *ContractMigrator {
return &ContractMigrator{
sourceDir: sourceDir,
targetDir: targetDir,
}
}
func (m *ContractMigrator) MigrateContract(contractName, fromVersion, toVersion string) error {
sourceFile := filepath.Join(m.sourceDir, fmt.Sprintf("%s_%s.json", contractName, fromVersion))
targetFile := filepath.Join(m.targetDir, fmt.Sprintf("%s_%s.json", contractName, toVersion))
// Read source contract
sourceData, err := ioutil.ReadFile(sourceFile)
if err != nil {
return fmt.Errorf("failed to read source contract: %w", err)
}
var sourceContract PactContract
if err := json.Unmarshal(sourceData, &sourceContract); err != nil {
return fmt.Errorf("failed to parse source contract: %w", err)
}
// Apply migration rules
migratedContract, err := m.applyMigrationRules(sourceContract, fromVersion, toVersion)
if err != nil {
return fmt.Errorf("failed to apply migration rules: %w", err)
}
// Write migrated contract
migratedData, err := json.MarshalIndent(migratedContract, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal migrated contract: %w", err)
}
if err := ioutil.WriteFile(targetFile, migratedData, 0644); err != nil {
return fmt.Errorf("failed to write migrated contract: %w", err)
}
return nil
}
type PactContract struct {
Consumer PactParty `json:"consumer"`
Provider PactParty `json:"provider"`
Interactions []PactInteraction `json:"interactions"`
Metadata PactMetadata `json:"metadata"`
}
type PactParty struct {
Name string `json:"name"`
}
type PactInteraction struct {
Description string `json:"description"`
ProviderState string `json:"providerState,omitempty"`
Request PactRequest `json:"request"`
Response PactResponse `json:"response"`
}
type PactRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Headers map[string]interface{} `json:"headers,omitempty"`
Body interface{} `json:"body,omitempty"`
}
type PactResponse struct {
Status int `json:"status"`
Headers map[string]interface{} `json:"headers,omitempty"`
Body interface{} `json:"body,omitempty"`
}
type PactMetadata struct {
PactSpecification Version `json:"pactSpecification"`
}
type Version struct {
Version string `json:"version"`
}
func (m *ContractMigrator) applyMigrationRules(
contract PactContract,
fromVersion,
toVersion string,
) (PactContract, error) {
migratedContract := contract
// Apply version-specific migration rules
switch {
case fromVersion == "1.0.0" && toVersion == "2.0.0":
return m.migrateV1ToV2(contract)
case fromVersion == "2.0.0" && toVersion == "3.0.0":
return m.migrateV2ToV3(contract)
default:
return contract, fmt.Errorf("no migration rules defined for %s to %s",
fromVersion, toVersion)
}
}
func (m *ContractMigrator) migrateV1ToV2(contract PactContract) (PactContract, error) {
migratedContract := contract
// Example: Add new optional fields, update response structure
for i, interaction := range migratedContract.Interactions {
if response, ok := interaction.Response.Body.(map[string]interface{}); ok {
// Add new optional email field to user responses
if _, exists := response["id"]; exists {
response["email"] = map[string]interface{}{
"matcher": map[string]interface{}{
"type": "type",
},
"value": "user@example.com",
}
}
migratedContract.Interactions[i].Response.Body = response
}
}
// Update metadata
migratedContract.Metadata.PactSpecification.Version = "3.0.0"
return migratedContract, nil
}
func (m *ContractMigrator) migrateV2ToV3(contract PactContract) (PactContract, error) {
// Implement V2 to V3 migration logic
return contract, nil
}
Monitoring and Observability
Comprehensive monitoring ensures contract testing provides actionable insights into system integration health.
Metrics Collection
package monitoring
import (
"context"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
contractTestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "contract_test_duration_seconds",
Help: "Duration of contract test execution",
Buckets: prometheus.DefBuckets,
},
[]string{"consumer", "provider", "test_type", "result"},
)
contractTestCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "contract_tests_total",
Help: "Total number of contract tests executed",
},
[]string{"consumer", "provider", "test_type", "result"},
)
contractValidationErrors = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "contract_validation_errors_total",
Help: "Total number of contract validation errors",
},
[]string{"consumer", "provider", "error_type"},
)
)
type ContractTestMonitor struct {
tracer trace.Tracer
meter metric.Meter
}
func NewContractTestMonitor() *ContractTestMonitor {
return &ContractTestMonitor{
tracer: otel.Tracer("contract-testing"),
meter: otel.Meter("contract-testing"),
}
}
func (m *ContractTestMonitor) RecordTestExecution(
ctx context.Context,
consumer, provider, testType string,
duration time.Duration,
success bool,
) {
result := "success"
if !success {
result = "failure"
}
// Record Prometheus metrics
contractTestDuration.WithLabelValues(consumer, provider, testType, result).
Observe(duration.Seconds())
contractTestCounter.WithLabelValues(consumer, provider, testType, result).Inc()
// Create OpenTelemetry span
_, span := m.tracer.Start(ctx, "contract_test_execution",
trace.WithAttributes(
attribute.String("consumer", consumer),
attribute.String("provider", provider),
attribute.String("test_type", testType),
attribute.Bool("success", success),
attribute.Float64("duration_seconds", duration.Seconds()),
),
)
defer span.End()
if !success {
span.SetStatus(trace.Status{Code: trace.StatusCodeError})
}
}
func (m *ContractTestMonitor) RecordValidationError(
ctx context.Context,
consumer, provider, errorType string,
) {
contractValidationErrors.WithLabelValues(consumer, provider, errorType).Inc()
_, span := m.tracer.Start(ctx, "contract_validation_error",
trace.WithAttributes(
attribute.String("consumer", consumer),
attribute.String("provider", provider),
attribute.String("error_type", errorType),
),
)
defer span.End()
span.SetStatus(trace.Status{Code: trace.StatusCodeError})
}
// Integration with contract testing
func MonitoredPactVerification(
monitor *ContractTestMonitor,
consumer, provider string,
testFunc func() error,
) error {
ctx := context.Background()
start := time.Now()
err := testFunc()
duration := time.Since(start)
monitor.RecordTestExecution(ctx, consumer, provider, "pact", duration, err == nil)
if err != nil {
monitor.RecordValidationError(ctx, consumer, provider, "verification_failed")
}
return err
}
Dashboard Configuration
# Grafana Dashboard Configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: contract-testing-dashboard
namespace: monitoring
data:
dashboard.json: |
{
"dashboard": {
"id": null,
"title": "Contract Testing Dashboard",
"tags": ["contract-testing", "microservices"],
"timezone": "browser",
"panels": [
{
"id": 1,
"title": "Contract Test Success Rate",
"type": "stat",
"targets": [
{
"expr": "sum(rate(contract_tests_total{result=\"success\"}[5m])) / sum(rate(contract_tests_total[5m])) * 100",
"legendFormat": "Success Rate"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"steps": [
{"color": "red", "value": 0},
{"color": "yellow", "value": 80},
{"color": "green", "value": 95}
]
}
}
}
},
{
"id": 2,
"title": "Contract Test Duration",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(contract_test_duration_seconds_bucket[5m])) by (le, consumer, provider))",
"legendFormat": "95th percentile - {{consumer}} -> {{provider}}"
},
{
"expr": "histogram_quantile(0.50, sum(rate(contract_test_duration_seconds_bucket[5m])) by (le, consumer, provider))",
"legendFormat": "50th percentile - {{consumer}} -> {{provider}}"
}
]
},
{
"id": 3,
"title": "Contract Validation Errors",
"type": "table",
"targets": [
{
"expr": "sum(increase(contract_validation_errors_total[1h])) by (consumer, provider, error_type)",
"format": "table"
}
]
}
]
}
}
Alerting Rules
# Prometheus Alerting Rules
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: contract-testing-alerts
namespace: monitoring
spec:
groups:
- name: contract_testing
rules:
- alert: ContractTestFailureRate
expr: |
(
sum(rate(contract_tests_total{result="failure"}[5m])) by (consumer, provider) /
sum(rate(contract_tests_total[5m])) by (consumer, provider)
) * 100 > 10
for: 2m
labels:
severity: warning
team: platform
annotations:
summary: "High contract test failure rate"
description: |
Contract tests between {{ $labels.consumer }} and {{ $labels.provider }}
have a failure rate of {{ $value }}% over the last 5 minutes.
- alert: ContractTestDurationHigh
expr: |
histogram_quantile(0.95, sum(rate(contract_test_duration_seconds_bucket[5m])) by (le, consumer, provider)) > 30
for: 5m
labels:
severity: warning
team: platform
annotations:
summary: "Contract test duration is high"
description: |
95th percentile contract test duration between {{ $labels.consumer }}
and {{ $labels.provider }} is {{ $value }}s, which exceeds the 30s threshold.
- alert: ContractValidationErrors
expr: |
increase(contract_validation_errors_total[10m]) > 0
labels:
severity: critical
team: platform
annotations:
summary: "Contract validation errors detected"
description: |
Contract validation errors detected between {{ $labels.consumer }}
and {{ $labels.provider }}: {{ $labels.error_type }}
Best Practices and Recommendations
Contract Design Principles
- Keep Contracts Minimal: Include only essential data that consumers actually use
- Use Realistic Test Data: Avoid placeholder data that might mask real integration issues
- Version Contracts Semantically: Follow semantic versioning for contract evolution
- Document Provider States: Clearly describe the conditions required for each interaction
Implementation Guidelines
- Automate Contract Publishing: Integrate contract publication into CI/CD pipelines
- Implement Contract Drift Detection: Monitor for unauthorized contract changes
- Use Contract Broker: Centralize contract storage and verification coordination
- Test Consumer-Provider Compatibility: Verify contracts before deployment
Performance Optimization
- Parallel Test Execution: Run contract tests in parallel where possible
- Resource Pooling: Reuse test infrastructure to reduce startup overhead
- Selective Testing: Only run relevant tests based on changed components
- Cache Contract Artifacts: Cache compiled contracts and test fixtures
Contract testing represents a paradigm shift toward more reliable, efficient integration testing for microservices architectures. By implementing consumer-driven contracts with tools like Pact or Spring Cloud Contract, organizations can achieve faster feedback cycles, improved system reliability, and better developer productivity. The key to success lies in treating contracts as first-class artifacts, integrating them into CI/CD pipelines, and maintaining them with the same rigor as production code.
Through careful implementation of the patterns and practices outlined in this guide, teams can build robust microservices ecosystems that scale with confidence while maintaining the agility that microservices architectures promise.