Contract Testing with Spring Cloud Contract

Introduction

This tutorial explains contract testing between two Spring Boot services with Spring Cloud Contract. We will see the problem contract tests solve, then build both sides of a real example: a book service that publishes a contract, from which Spring Cloud Contract generates tests for the book service and stubs for its clients, and an order service whose tests run against those stubs. Finally, the book service makes a breaking change, and we will see its own build stop it.

The problem: two services, two test suites

An order service calls a book service over HTTP. The order service's tests cannot call the real book service - it may not be running, and its data changes - so they use a hand-written stub that answers the way the book service is believed to answer. The book service has its own tests. Both suites pass, and yet the two services can be incompatible: if the book service renames a JSON field, its tests are updated with it, the order service's stub still has the old name, and the mismatch is found only when the two run together, in an integration environment or in production.

A contract closes that gap. It is one description of a request and its expected response, owned by the producer - the book service - and used on both sides: the producer's build turns it into tests that the real controller must pass, and into stubs that the consumers' tests call. Since the stubs come from the same contract that the producer's tests prove, they cannot drift from the real service. When the contracts are written from what the consumers need, often proposed by the consumer team as a pull request to the producer, this is called consumer-driven contract testing - Spring Cloud Contract - the producer's contract becomes generated tests that the real controller must pass and a stubs jar in a Maven repository, which the consumer's stub runner starts as a WireMock server for the consumer's tests

Versions used

Spring Cloud Contract is no longer part of the Spring Cloud release train: neither the spring-cloud-dependencies 2025.1.4 BOM nor 2026.0.0-RC2 lists it. It is released on its own, with its own BOM, spring-cloud-contract-dependencies. The latest release, 5.0.3, is the one used here, with Spring Boot 4.1.2 and Java 25; both builds below ran with this combination without any compatibility complaint.

The producer: book-service

The book service is a plain Spring Boot web application. Its build adds the contract verifier starter for tests, and the Spring Cloud Contract Maven plugin, which generates the tests and the stubs. baseClassForTests names the class that the generated tests extend -
pom.xml of book-service
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.2</version>
    <relativePath/>
  </parent>
  <groupId>com.javabrahman</groupId>
  <artifactId>book-service</artifactId>
  <version>1.0.0</version>

  <properties>
    <java.version>25</java.version>
    <spring-cloud-contract.version>5.0.3</spring-cloud-contract.version>
  </properties>

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

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-contract-verifier</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
      <plugin>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-contract-maven-plugin</artifactId>
        <version>${spring-cloud-contract.version}</version>
        <extensions>true</extensions>
        <configuration>
          <testFramework>JUNIT5</testFramework>
          <baseClassForTests>com.javabrahman.books.ContractTestBase</baseClassForTests>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
src/main/java/.../Book.java
package com.javabrahman.books;

public record Book(long id, String title, int price) {
}
src/main/java/.../BookController.java
package com.javabrahman.books;

import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BookController {
  private final Map<Long, Book> books = Map.of(
      1L, new Book(1, "Effective Java", 450),
      2L, new Book(2, "Clean Code", 380));

  @GetMapping("/books/{id}")
  public ResponseEntity<Book> book(@PathVariable long id) {
    Book book = books.get(id);
    return book == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(book);
  }
}
The contracts live in src/test/resources/contracts, here in the Groovy DSL; YAML, Java and Kotlin are supported as well. Each one says what request to send and what response to expect -
src/test/resources/contracts/shouldReturnABook.groovy
package contracts

import org.springframework.cloud.contract.spec.Contract

Contract.make {
  description "returns the book with the given id"
  request {
    method GET()
    url "/books/1"
  }
  response {
    status OK()
    headers {
      contentType applicationJson()
    }
    body(
        id: 1,
        title: "Effective Java",
        price: 450
    )
  }
}
src/test/resources/contracts/shouldReturn404ForAnUnknownBook.groovy
package contracts

import org.springframework.cloud.contract.spec.Contract

Contract.make {
  description "answers 404 for a book that does not exist"
  request {
    method GET()
    url "/books/99"
  }
  response {
    status NOT_FOUND()
  }
}
The base class prepares what the generated tests call. Here it sets up the controller with RestAssured's MockMvc support, standalone, without an application context; a base class may also be a @SpringBootTest, or a @WebMvcTest with mocked services, when the controller needs them -
src/test/java/.../ContractTestBase.java
package com.javabrahman.books;

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;

// The generated contract tests extend this class: it sets up the controller that they call
public abstract class ContractTestBase {

  @BeforeEach
  void setUp() {
    RestAssuredMockMvc.standaloneSetup(new BookController());
  }
}
 OUTPUT of mvn clean install for book-service (the lines about the contracts, the tests and the result)
[INFO] Generated 1 test classes.
[INFO] Converting from Spring Cloud Contract Verifier contracts to WireMock stubs mappings
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

What the plugin generated

The plugin wrote one test class, which extends the base class and has one test per contract. It was compiled and run by Maven like any other test, and both tests passed against the real controller -
target/generated-test-sources/contracts/.../ContractVerifierTest.java - generated
package com.javabrahman.books;

import com.javabrahman.books.ContractTestBase;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import io.restassured.response.ResponseOptions;

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;

@SuppressWarnings("rawtypes")
public class ContractVerifierTest extends ContractTestBase {

	@Test
	public void validate_shouldReturn404ForAnUnknownBook() throws Exception {
		// given:
			MockMvcRequestSpecification request = given();


		// when:
			ResponseOptions response = given().spec(request)
					.get("/books/99");

		// then:
			assertThat(response.statusCode()).isEqualTo(404);
	}

	@Test
	public void validate_shouldReturnABook() throws Exception {
		// given:
			MockMvcRequestSpecification request = given();


		// when:
			ResponseOptions response = given().spec(request)
					.get("/books/1");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
			assertThat(response.header("Content-Type")).matches("application/json.*");

		// and:
			DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
			assertThatJson(parsedJson).field("['id']").isEqualTo(1);
			assertThatJson(parsedJson).field("['title']").isEqualTo("Effective Java");
			assertThatJson(parsedJson).field("['price']").isEqualTo(450);
	}

}
It also converted each contract into a WireMock stub mapping, and packed the mappings and the contracts into a second jar, book-service-1.0.0-stubs.jar, which mvn install put into the local Maven repository next to the application jar. In a team, mvn deploy publishes it to the shared repository instead -
the stub mapping for shouldReturnABook - generated
{
  "id" : "056368b2-794d-4509-9b8e-6f4acb316c4a",
  "request" : {
    "url" : "/books/1",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "{\"id\":1,\"title\":\"Effective Java\",\"price\":450}",
    "headers" : {
      "Content-Type" : "application/json"
    },
    "transformers" : [ "response-template", "spring-cloud-contract" ]
  },
  "uuid" : "056368b2-794d-4509-9b8e-6f4acb316c4a"
}

The consumer: order-service

The order service calls the book service with a RestClient, and keeps its own small view of a book, with only the fields it needs. Its tests add the stub runner starter -
src/main/java/.../BookClient.java
package com.javabrahman.orders;

import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class BookClient {
  private final RestClient http;

  public BookClient(RestClient.Builder builder, @Value("${books.url}") String booksUrl) {
    this.http = builder.baseUrl(booksUrl).build();
  }

  public Optional<BookInfo> find(long id) {
    return http.get().uri("/books/{id}", id)
        .exchange((request, response) -> response.getStatusCode() == HttpStatus.NOT_FOUND
            ? Optional.empty()
            : Optional.ofNullable(response.bodyTo(BookInfo.class)));
  }

  // the consumer's own view of a book: only the fields it needs
  public record BookInfo(String title, int price) {
  }
}
src/main/java/.../OrderService.java
package com.javabrahman.orders;

import org.springframework.stereotype.Service;

@Service
public class OrderService {
  private final BookClient books;

  public OrderService(BookClient books) {
    this.books = books;
  }

  public String quote(long bookId, int quantity) {
    return books.find(bookId)
        .map(book -> quantity + " x " + book.title() + " = Rs. " + quantity * book.price())
        .orElse("no such book: " + bookId);
  }
}
pom.xml of order-service - the test dependency (the BOM is imported as in book-service)
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
  <scope>test</scope>
</dependency>
@AutoConfigureStubRunner takes the stubs' coordinates as group:artifact:version:classifier:port. The version + means the latest one available, and StubsMode.LOCAL looks for it in the local Maven repository; REMOTE with a repositoryRoot fetches it from a shared one. The stub runner starts a WireMock server on port 18090 with the producer's mappings, and the test points the client at it -
src/test/java/.../OrderServiceContractTest.java
package com.javabrahman.orders;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties.StubsMode;

@SpringBootTest(properties = "books.url=http://localhost:18090")
@AutoConfigureStubRunner(ids = "com.javabrahman:book-service:+:stubs:18090", stubsMode = StubsMode.LOCAL)
class OrderServiceContractTest {

  @Autowired
  OrderService orders;

  @Test
  void quotesAKnownBook() {
    String quote = orders.quote(1, 3);
    System.out.println("quote: " + quote);
    assertThat(quote).isEqualTo("3 x Effective Java = Rs. 1350");
  }

  @Test
  void handlesAnUnknownBook() {
    String quote = orders.quote(99, 1);
    System.out.println("quote: " + quote);
    assertThat(quote).isEqualTo("no such book: 99");
  }
}
 OUTPUT of mvn test for order-service (the stub runner's lines, the test's own lines and the result)
Started stub server for project [com.javabrahman:book-service:1.0.0:stubs] on port 18090 with [2] mappings
All stubs are now running RunningStubs [namesAndPorts={com.javabrahman:book-service:1.0.0:stubs=18090}]
quote: no such book: 99
quote: 3 x Effective Java = Rs. 1350
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Explanation of the output

  • The stubs: the stub runner found book-service:1.0.0:stubs - the latest version, for + - and started it on port 18090 with the two mappings, one per contract.
  • Real HTTP: the order service's BookClient made real HTTP calls to the stub server, so the test covered the client's URL, its JSON mapping and its handling of a 404 - things a mocked BookClient would have hidden.
  • Proven answers: the 200 with the price 450 and the 404 for book 99 are exactly the answers the producer's generated tests proved its controller gives.
The port is fixed here. The stub runner can also pick a free port, when the port is left out of the coordinates, and tell the test with @StubRunnerPort("book-service"); a client that reads its URL from a property at startup, as this one does, needs the port before the context starts, so a fixed port is the simpler choice for it.

A breaking change

Now the book service team renames the field price to priceInr, in a copy of the project, book-service-renamed, that differs only in this line -
src/main/java/.../Book.java in book-service-renamed
public record Book(long id, String title, int priceInr) {
}
 OUTPUT of mvn test for book-service-renamed (the failure and the result)
java.lang.IllegalStateException: Parsed JSON [{"id":1,"title":"Effective Java","priceInr":450}] doesn't match the JSON path [$[?(@.['price'] == 450)]]
[ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0
[INFO] BUILD FAILURE
The generated test for shouldReturnABook looked for price with the value 450, found priceInr instead, and failed the producer's build. No stubs jar with the new field was published, and the order service, which reads price, was never exposed to the change. The book service team now has two honest options: keep the old field - adding priceInr next to it, if needed - or change the contract, which then also changes the stubs, so that the consumers' tests show who is affected before the change reaches them.

Good practice with contracts

  • Contracts describe what consumers use, not every field the producer returns. A contract that lists only the fields a consumer reads leaves the producer free to change the rest.
  • Use matchers for values that vary: the DSL can accept any value of a pattern - an id, a timestamp - instead of one fixed value, through its bodyMatchers and $(consumer(...), producer(...)) forms.
  • Contract tests do not test business logic: they check the shape of the conversation. The base class can mock the services behind the controller; the logic is covered by the producer's own tests.
  • Publish the stubs from CI, with the application jar, so that consumers always test against what the producer really proved.

Summary

In this tutorial we tested the contract between two services with Spring Cloud Contract 5.0.3 and Spring Boot 4.1.2. The book service's contracts, in the Groovy DSL, made the Maven plugin generate a test class that the real controller passed, and a stubs jar with WireMock mappings. The order service's @AutoConfigureStubRunner started those stubs, and its client made real HTTP calls against them. When the book service renamed a field, its own build failed, before any consumer could be broken. The next tutorial is about keeping a growing test suite fast and reliable: testing best practices and faster test suitesClick to read tutorial: Spring Boot Testing Best Practices and Faster Test Suites.