1. The Problem
A common problem often arises with microservices: clients can break in the event that the implementation of a service’s API changes. Endpoints can change, representations of information received over the wire can change.
Our stub is at the moment a correct representation of the fortune service. But as soon as the fortune service’s API changes, this stub will stop being a reflection of reality. And our tests are in danger of continuing to pass, even though in production our client’s calls would fail against the true, newly-updated backing service.
In other words, we have a vulnerability: we are not alerted to a breaking change in the contract.
This is one of the main problems that consumer-driven contract libraries are here to solve: is there a way, as soon as a contract changes, to alert clients to this fact?
Let’s explore the Spring Cloud Contract project.
2. Spring Cloud Contract
The main ideas in the Spring Cloud Contract project are as follows:
-
Encode a service’s API in a groovy dsl, known as the Contract Definition Language
-
Generate tests that verify that the implementation of the service adheres to the contract
-
Generate stubs that adhere to and reflect the contract, so that clients always test against an up-to-date representation of the contract
2.1. Fortune Service Contract
In this simple scenario, the contract can be summed up as follows: the fortune service should expose a single endpoint (/) that can be accessed with an HTTP GET, and that will return a json object with a single property named fortune whose value is the fortune itself, a phrase.
Here’s a valid spring cloud contract that represents the above:
description "The fortune service API"
request {
method 'GET'
url '/'
}
response {
status 200
headers {
contentType(applicationJson())
}
body """
{ "fortune": "a random fortune" }
"""
}
The above contract hard-codes a value for the fortune. It doesn’t have to, it could instead specify that the value is non-blank, like so:
body """
{ "fortune": ${nonBlank()} }
"""
The above is an example of a groovy domain-specific language. nonBlank()
is a built-in function that resolves to a regular expression that ensures the value of fortune is not blank. Groovy is in some ways ideal for the job of expressing contracts, for these reasons:
-
It’s easy to develop DSL’s in groovy
-
Support for command-completion in IDE’s
-
Features such as heredocs in groovy make it easy to encode json without having to deal with escaping double quotes; they also support the interpolation of variables and expressions.
2.1.1. Where to place the contract?
By default, Spring Cloud Contract expects to find contracts in a service’s test resources folder, in a subdirectory named contracts. For each contract, create a subdirectory within contracts, in this case we’ll name it fortunes. The groovy file can have any name we want, let’s call it fortunesContract.groovy
. Here’s the complete implementation, including the package declaration and import statement:
package fortunes
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "The fortune service API"
request {
method 'GET'
url '/'
}
response {
status 200
headers {
contentType(applicationJson())
}
body """
{ "fortune": "a random fortune" }
"""
}
}
Be sure to place the above file in the fortune service, under src/test/resources/contracts/fortunes
.
2.1.2. Dependencies
For this contract to be properly interpreted, we need to update our build file to include the necessary dependencies. Below we show in diff form, what additions need to be made to the fortune-service’s build.gradle file in order to add this dependency:
@@ -1,6 +1,7 @@
buildscript {
ext {
springBootVersion = '1.5.10.RELEASE'
+ contractVerifierVersion = '1.2.3.RELEASE'
}
repositories {
mavenCentral()
@@ -21,6 +22,11 @@
mavenCentral()
}
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-contract-dependencies:${contractVerifierVersion}"
+ }
+}
dependencies {
compile 'org.springframework.boot:spring-boot-starter-web'
@@ -31,4 +37,5 @@
compileOnly 'org.projectlombok:lombok'
testCompile 'org.springframework.boot:spring-boot-starter-test'
+ testCompile 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
}
2.2. Test Generation
Another component of Spring Cloud Contract is a build plugin that adds tasks to generate contract tests.
Modify the build file once more, to apply that gradle plugin to our build, and to configure it:
@@ -8,9 +8,12 @@
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
+ classpath "org.springframework.cloud:spring-cloud-contract-gradle-plugin:${contractVerifierVersion}"
}
}
+apply plugin: 'spring-cloud-contract'
+
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
@@ -28,6 +31,11 @@
}
}
+contracts {
+ packageWithBaseClasses = 'io.pivotal.training.fortune' (1)
+ basePackageForTests = 'io.pivotal.training.fortune' (2)
+}
+
dependencies {
compile 'org.springframework.boot:spring-boot-starter-web'
compile 'org.springframework.boot:spring-boot-starter-actuator'
1 | generate tests in the specified java package |
2 | tests will extend a base class, and base class can be found in the specified java package |
With these changes in place, we now have additional gradle tasks:
$ gradle tasks
...
Verification tasks
------------------
check - Runs all checks.
copyContracts - Copies contracts to the output folder
generateClientStubs - Generate client stubs from the contracts
generateContractTests - Generate server tests from the contracts
generateWireMockClientStubs - DEPRECATED: Generate WireMock client stubs from the contracts. Use generateClientStubs task.
test - Runs the unit tests.
verifierStubsJar - Creates the stubs JAR task
In particular, note the generateContractTests
target. Go ahead and invoke it.
$ gradle generateContractTests
By default tests are written to this directory: build/generated-test-sources/contracts
. Here is what the generated test looks like:
package io.pivotal.training.fortune;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import io.restassured.response.ResponseOptions;
import io.pivotal.training.fortune.FortunesBase;
import org.junit.Test;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
public class FortunesTest extends FortunesBase {
@Test
public void validate_fortunesContract() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get("/");
// 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("['fortune']").isEqualTo("a random fortune");
}
}
It’s basically a controller test, just like the one we wrote in the previous section, the main difference being that this test uses a popular testing library named RestAssured.
For these tests to run, we need to write the base class. Here, Spring Cloud Contract employs a convention: it will look for a class named FortunesBase.java
, the prefix (Fortunes) has to match the name of the directory that the contract DSL is located in.
Here’s an implementation that will suit us:
package io.pivotal.training.fortune;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import static org.mockito.Mockito.when;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.MOCK;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = MOCK)
public abstract class FortunesBase {
@MockBean private FortuneService fortuneService;
@Autowired private FortuneController fortuneController;
@Before
public void setup() {
when(fortuneService.getFortune()).thenReturn("a random fortune");
RestAssuredMockMvc.standaloneSetup(fortuneController);
}
}
This should look familiar, it’s similar to the way the class FortuneControllerTests
is configured. Here we mock the fortune service to make it return the same value we encoded in the contract.
Now we’re ready to run our tests:
$ gradle test
The tests should pass. Check the gradle test report located at build/reports/tests/test/index.html
.
At this point, the test we wrote in the last section, in FortuneControllerTests
, is now essentially a duplicate of what Spring Cloud has generated for us. We’re free to delete it.
2.3. Generating the Stub
Spring Cloud Contract has one more trick up its sleeve: it can auto-generate and publish a wiremock stub to a maven repository alongside the service’s main artifact. To do that, Spring Cloud Contract uses a maven classifier named 'stubs' to identify this extra artifact.
Here we will publish our service to our local maven repository. gradle makes this particularly simple. Let’s give our fortune-service the maven groupId io.pivotal.training.springcloud
and apply the gradle maven-publish
plugin:
@@ -12,10 +12,7 @@ buildscript {
}
}
-group = 'io.pivotal.training.fortune'
+group = 'io.pivotal.training.springcloud'
+
apply plugin: 'spring-cloud-contract'
+apply plugin: 'maven-publish'
apply plugin: 'java'
apply plugin: 'eclipse'
Now we’re ready to publish our project (including the stub) to maven local:
$ gradle publishToMavenLocal
2.4. The Stub Runner
Finally, let’s turn our attention to the greeting application.
Spring Cloud Contract provides a stub runner, whose dependency needs to be added to our project:
@@ -21,6 +21,9 @@
mavenCentral()
}
+ext {
+ springCloudVersion = 'Edgware.SR2'
+}
dependencies {
compile 'org.springframework.boot:spring-boot-starter-web'
@@ -32,6 +35,12 @@
compileOnly 'org.projectlombok:lombok'
testCompile 'org.springframework.boot:spring-boot-starter-test'
- testCompile 'com.github.tomakehurst:wiremock-standalone:2.14.0' (1)
+ testCompile 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
+}
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
+ }
}
1 | Wiremock becomes a transitive dependency of the runner, we don’t need to cite it explicitly anymore. |
Next, with a single annotation, we can replace our hand-coded wiremock stub with the fortune-service’s auto-generate stub, the one that is a reflection of our contract, and that should remain in synch with that contract:
@@ -1,42 +1,23 @@
package io.pivotal.training.greeting;
-import com.github.tomakehurst.wiremock.client.WireMock;
-import com.github.tomakehurst.wiremock.junit.WireMockRule;
-import org.junit.Before;
-import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
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.test.context.junit4.SpringRunner;
-import static com.github.tomakehurst.wiremock.client.WireMock.*;
-import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = NONE)
+@AutoConfigureStubRunner(workOffline = true, ids = "io.pivotal.training.springcloud:fortune-service:+:stubs:8081") (1)
public class FortuneServiceClientTests {
@Autowired private FortuneServiceClient fortuneServiceClient;
- @Rule (2)
- public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().port(8081));
-
- private static final String ExpectedFortune = "some fortune";
-
- @Before
- public void setup() {
- stubFor(WireMock.get(urlEqualTo("/"))
- .willReturn(aResponse()
- .withStatus(200)
- .withHeader("Content-Type", "application/json")
- .withBody(String.format("{ \"fortune\": \"%s\" }", ExpectedFortune))
- ));
- }
+ private static final String ExpectedFortune = "a random fortune";
@Test
- public void shouldReturnAFortune() {
- assertThat(fortuneServiceClient.getFortune()).isNotBlank();
+ public void shouldReturnAFortuneMatchingContract() {
+ assertThat(fortuneServiceClient.getFortune()).isEqualTo(ExpectedFortune);
}
1 | workOffline means "fetch dependencies from maven local", ids tells where in the repository to find the stub, using the 'stubs' classifier and specifying a port number to run our stub on (8081) |
2 | Removing all the wiremock-related code and replacing it with the AutoConfigureStubRunner annotation |
This test should now pass.
3. A "what-if" Scenario
The big question now is: will our client tests alert us (i.e. fail) if the developers of the fortune service change the contract without notifying us of the breaking change?
Let’s test that:
Pretend that the fortune service developers chose to rename the field in the json from 'fortune' to 'message'. Edit fortunesContract.groovy
such that the body looks like this:
body """
{ "message": "a random fortune" }
"""
}
The first thing we will encounter is a failing contract implementation on the fortune side.
$ gradle clean test
...
java.lang.IllegalStateException: Parsed JSON [{"fortune":"a random fortune"}] doesn't match the JSON path [$[?(@.['message'] == 'a random fortune')]]
Retrofit the implementation of FortuneController to put the fortune using the key message. And otherwise make the fortune tests pass.
Now, let’s publish our updated artifacts to maven:
$ gradle publishToMavenLocal
And finally, let’s do a clean run of the greeting application’s tests. The FortuneServiceClientTests
should fail.
That’s the role of contract tests: keeping both sides of the contract in sync with one another, and alerting clients early to breaking changes in the contract.
➜ In preparation for the next lab, undo the above changes associated with the what-if scenario: set the key name in the body of the json repsonse back to fortune
, and re-publish to mavenLocal.
4. Congralutations
We’ve covered a lot of ground; I hope this lab gave you a taste of how Spring Cloud Contract works. If you’re curious to learn more, head on over to the project’s reference guide.
Next, let’s talk about failures.