Post

Contract Testing with Spring Cloud Contract — Consumer-Driven Contracts

Ensure microservices don't break each other with Spring Cloud Contract. Covers consumer-driven contracts, Groovy DSL, auto-generated tests, stub runner, and the workflow between producer and consumer teams.

Contract Testing with Spring Cloud Contract — Consumer-Driven Contracts

The Problem — Integration Testing Across Services

You have two microservices. The Order Service calls the Payment Service. One day, the Payment team renames a field from amount to totalAmount. Their tests pass. They deploy. Your Order Service breaks in production.

This happens because:

  • Unit tests don’t catch it (they mock the HTTP call)
  • Integration tests are slow and flaky (they need both services running)
  • E2E tests catch it too late (after deployment)

Contract testing solves this: the consumer defines what it expects, the producer verifies it can deliver.


What Are Contracts

A contract is a formal agreement between two services about the shape of their HTTP interaction.

graph LR
    A[Consumer<br/>Order Service] -->|"Defines expectations<br/>(what I call, what I expect)"| B[Contract<br/>Groovy DSL]
    B -->|"Auto-generates tests"| C[Producer<br/>Payment Service]
    C -->|"Generates stubs"| D[Stub JAR]
    D -->|"Used in tests"| A

diagram

The contract lives in the producer’s repository but represents the consumer’s expectations. If the producer breaks the contract, their build fails — before deployment.


Setup

Parent POM (Spring Cloud BOM)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<properties>
    <spring-cloud.version>2024.0.0</spring-cloud.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Producer Dependencies

1
2
3
4
5
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-verifier</artifactId>
    <scope>test</scope>
</dependency>

Consumer Dependencies

1
2
3
4
5
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    <scope>test</scope>
</dependency>

Writing a Contract — Groovy DSL

Contracts are written in Groovy and placed in src/test/resources/contracts/:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.springframework.cloud.contract.spec.Contract

Contract.make {
    name "should return payment by id"
    description "When GET /api/payments/1 is called, return payment details"

    request {
        method GET()
        url "/api/payments/1"
        headers {
            contentType(applicationJson())
        }
    }

    response {
        status OK()
        headers {
            contentType(applicationJson())
        }
        body(
            id: 1,
            orderId: "ORD-2024-001",
            amount: 99.99,
            currency: "USD",
            status: "COMPLETED"
        )
        bodyMatchers {
            jsonPath('$.id', byType())
            jsonPath('$.orderId', byRegex('[A-Z]{3}-\\d{4}-\\d{3}'))
            jsonPath('$.amount', byType())
            jsonPath('$.currency', byRegex('[A-Z]{3}'))
            jsonPath('$.status', byRegex('COMPLETED|PENDING|FAILED'))
        }
    }
}

Key elements:

  • body(...) — the concrete example used for stubs
  • bodyMatchers — flexible matchers for the generated tests (types, regexes)

Producer Side — Auto-Generated Tests

When you run mvn clean test on the producer, Spring Cloud Contract:

  1. Reads contracts from src/test/resources/contracts/
  2. Generates JUnit 5 test classes
  3. Runs them against your controller using a base test class

The base test class sets up MockMvc:

1
2
3
4
5
6
7
8
9
10
11
12
13
@SpringBootTest
public abstract class BaseContractTest {

    @Autowired
    private PaymentController paymentController;

    @BeforeEach
    void setup() {
        StandaloneMockMvcBuilder standaloneMockMvcBuilder =
                MockMvcBuilders.standaloneSetup(paymentController);
        RestAssuredMockMvc.standaloneSetup(standaloneMockMvcBuilder);
    }
}

Configure the plugin to use this base class:

1
2
3
4
5
6
7
8
9
10
11
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <extensions>true</extensions>
    <configuration>
        <testFramework>JUNIT5</testFramework>
        <baseClassForTests>
            com.anupam.contract.producer.BaseContractTest
        </baseClassForTests>
    </configuration>
</plugin>

If the producer changes the response shape (renames a field, changes a status code), the auto-generated test fails.


Consumer Side — StubRunner

The consumer doesn’t call the real producer. Instead, it uses stubs generated from the contracts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootTest
@AutoConfigureStubRunner(
        ids = "com.anupam:contract-testing-producer:+:stubs:8090",
        stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class PaymentClientContractTest {

    @Test
    void shouldGetPaymentFromProducerStub() {
        PaymentClient paymentClient = new PaymentClient("http://localhost:8090");

        PaymentClient.PaymentResponse payment = paymentClient.getPayment(1L);

        assertThat(payment).isNotNull();
        assertThat(payment.id()).isEqualTo(1L);
        assertThat(payment.orderId()).matches("[A-Z]{3}-\\d{4}-\\d{3}");
        assertThat(payment.amount()).isGreaterThan(BigDecimal.ZERO);
        assertThat(payment.currency()).matches("[A-Z]{3}");
        assertThat(payment.status()).isIn("COMPLETED", "PENDING", "FAILED");
    }
}

@AutoConfigureStubRunner starts a WireMock server on port 8090 loaded with the producer’s stubs. The consumer’s HTTP client calls this stub server exactly like it would call the real producer.


The Workflow

sequenceDiagram
    participant Consumer Team
    participant Producer Repo
    participant CI/CD
    participant Artifact Repo

    Consumer Team->>Producer Repo: PR with new contract (shouldReturnPayment.groovy)
    Producer Repo->>CI/CD: Build triggered
    CI/CD->>CI/CD: Generate tests from contracts
    CI/CD->>CI/CD: Run tests against producer code
    alt Tests pass
        CI/CD->>Artifact Repo: Publish stub JAR
        CI/CD-->>Consumer Team: PR merged, stubs available
        Consumer Team->>Artifact Repo: Pull stubs
        Consumer Team->>Consumer Team: Run tests with StubRunner
    else Tests fail
        CI/CD-->>Producer Repo: Build fails
        CI/CD-->>Consumer Team: Producer doesn't support this contract yet
    end

diagram

This workflow ensures:

  • Breaking changes are caught before deployment
  • Consumer and producer can develop independently
  • Stubs are always in sync with the producer’s actual behavior

Contract Testing vs Integration vs E2E

AspectContract TestsIntegration TestsE2E Tests
SpeedFast (ms)Slow (seconds)Very slow (minutes)
InfrastructureNone (stubs)All services runningFull environment
FlakinessLowMediumHigh
Catches API breaksYesYesYes
Catches logic bugsNoYesYes
Catches UI bugsNoNoYes
Maintenance costLowMediumHigh
Feedback speedImmediate (in CI)After deploymentAfter full deploy
Runs whereUnit test phaseSeparate phaseStaging environment

Contract tests fill the gap between unit tests and integration tests. They verify that services can talk to each other without requiring all services to be running.


Common Problems

ProblemCauseSolution
“No base class found”Plugin misconfiguredSet baseClassForTests in spring-cloud-contract-maven-plugin
Stub not foundProducer not installed locallyRun mvn install on producer first
Port conflictStubRunner port in useChange port in @AutoConfigureStubRunner(ids = "...:stubs:9090")
Contract body mismatchController returns different structureFix controller or update contract
“No matching stub”Request doesn’t match contractCheck URL, method, and headers in contract
Groovy compilation errorSyntax error in contractValidate Groovy DSL (watch for missing commas)
Spring Cloud version mismatchBOM not importedAdd spring-cloud-dependencies BOM to dependencyManagement

When to Use Contracts

Contract testing works best for:

  • Microservices owned by different teams — the contract is the handshake
  • APIs that evolve frequently — catch breaking changes immediately
  • Services with many consumers — one contract per consumer, all verified

Skip contracts when:

  • You own both services and deploy them together (just write integration tests)
  • The API is truly public with unknown consumers (use OpenAPI + semantic versioning)
  • The interaction is fire-and-forget messaging with no response (use schema registries instead)

Full Working Example

Complete producer and consumer projects:

https://github.com/AnupamSinha/spring-boot-examples/tree/main/31-contract-testing

1
2
3
4
5
6
7
# Build producer (generates stubs)
cd 31-contract-testing/producer
mvn clean install

# Run consumer tests (uses stubs)
cd ../consumer
mvn clean test

References

This post is licensed under CC BY 4.0 by the author.