Using Spring Cloud Contract
Wouldn't it be great if the consumers of your services could tell you exactly how they expect your service to function? What if they could provide you with executable tests that would verify that you provide the service precisely as the consumer uses it? What if you could be confident that changes you make to your service do not break your clients? What if you did not have to stand up instances of your service just so your clients can test?
This is exactly the set of features contract testing provides. Here at mimacom we use the Spring Framework heavily, so it only makes sense that we use Spring Cloud Contract for our contract testing. It leverages WireMock to and Spring Integration to test your services and provide stubs for client applications.
What We're Building
For the purpose of this exercise, we will be building two microservices, one client and one service. The client is the sign-service that can produce signs of various shapes with the price of each sign dependent on the amount of material used. To know how to price any given sign, it needs to know the area of the sign. We will create a shape-service that knows how to do the area calculation. You can download source code from Github.
When you first download the source, it is a pair of basic SpringBoot web projects with Actuator and Lombok support added. The shape-service contains one rest controller, one service, and tests to exercise the two.
By default the shape-service will start on port 8082
. The sign-service contains one service, one client, tests, and some configuration. By default the sign-service will start on port 8081
. Before we begin, lets make sure everything is setup correctly:
First run the tests without enabling the integration test:
# Mac or Linux
./gradlew test
# Windows
gradle.bat test
From this point on, I'll be using the Mac/Linux commands but you can simply substitute gradle.bat
where you see ./gradlew
if you are on a Windows machine.
Open ShapeClientTest.java
in the sign-service and comment out the @Ignore
line
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class ShapeClientTest {
@Autowired
ShapeClient shapeClient;
@Test
//@Ignore
public void getTriangle_integration_test() throws Exception {
Shape triangle = shapeClient.getTriangle(100.0, 100.0);
BDDAssertions.then(triangle.getArea()).isEqualTo(5000.0);
}
}
Open a terminal at the checkout location and run the services:
# Mac or Linux
./gradlew bootRun --parallel
# Windows
gradle.bat bootRun --parallel
Wait for both services to start an open a second terminal at the checkout location and run the test:
# Mac or Linux
./gradlew test
# Windows
gradle.bat test
If everything is working correctly, you won't have any test failures.
Go ahead and uncomment the @Ignore
on the integration test in ShapeClientTest.java
.
The Problem
You might be thinking: "If we have unit tests and we have integration tests, what problem are we trying to solve here?" That's a good question and a perhaps a valid criticism for small systems where all services are maintained by a single team. However, if the system is large, complex, or has components maintained by separate teams, it is helpful to have an automated way to validate that a service behaves in the way the consumers behave.
Without contract tests, developers must rely on API documentation to use services correctly, or worse, interface agreements negotiated by managers. Developers will write their unit tests based on their assumptions and mock out the external services based on their understanding of the interface. The flaws in their assumptions are not revealed until integration testing is accomplished.
To demonstrate this point, checkout the "broken" branch from from the repository and go through the same exercise as before, run the tests without enabling integration tests, comment the @Ignore
on the integration test, start the services, and run the tests again.
What happened? Why did the unit tests pass but the integration test fail?
The confusion comes down to describing the dimensions of the triangle. Typically, triangles are described as having a height and a width while rectangles are described as having a length and a width. The developers of the shape-service didn't see the utility in having two terms that described essentially the same value, so they chose to use the rectangle's terminology to describe dimensions. But the developers of the sign-service assumed the shape-service would use standard terminology and overlooked the difference in the API documentation. This all could have been prevented if some contract tests were in place.
Consumer Driven Contracts
Consumer Driven Contracts are executable tests written by the consumer of a service to unambiguously define the way they intend to use a service. In Spring Cloud Contract, they are written as either Groovy or YML (we'll be using Groovy). They should be written by the consumer and submitted as a pull request to service project. As an added bonus, the mocked service stubs for clients can get published along with your Maven artifacts. Spring Cloud Contract even includes automatic generation of WireMock instances of the stubbed service for integration into client tests.
Sample HTTP Contract
import org.springframwork.cloud.contract.spec.Contract
Contract.make {
description "some description"
request {
url "/someUrl"
method GET()
}
response {
status 200
body("some response body")
}
}
In this contract, we're issuing a GET request to /someUrl
and expecting a response of "some response body" with a status code of 200.
Sample Message Queue Contract
import org.springframwork.cloud.contract.spec.Contract
Contract.make {
description "some description"
label "meaningfulName"
input {
triggeredBy baseClassTriggerMethod()
}
outputMessage {
sentTo "queueName"
body("some message body")
}
}
Message queue contracts are a little different. It executes the triggeredBy
method in the base class and expects a message with "some message body" to be sent to message queue "queueName".
The Shape Service
If you're following along, check out the master branch again, we'll use that as the starting point for these examples.
Configuration
To get the shape-service ready to accept contracts, we need to do configuration. First is to add the following dependencies to the build.gradle
in the shape-service:
Add the Spring Cloud Contract dependencies BOM to your dependency management imports:
mavenBom "org.springframework.cloud:spring-cloud-contract-dependencies:${springCloudContractVersion}"
Add the testCompile dependencies to the dependencies section
testCompile('org.springframework.cloud:spring-cloud-contract-wiremock')
testCompile('org.springframework.cloud:spring-cloud-starter-contract-verifier')
Add the Spring Cloud Contract gradle plugin dependency to the build script dependencies:
classpath ("org.springframework.cloud:spring-cloud-contract-gradle-plugin:${springCloudContractVersion}")
Add the Spring Cloud Contract plugin below all of the other plugins:
apply plugin: 'spring-cloud-contract'
Add a section to define how contract tests should be executed:
contracts {
baseClassForTests = 'com.mimacom.shapeservice.BaseClass'
}
Next create BaseClass.java
in shape-service/src/test/java/com/mimacom/shapeservice/`and populate it with the following:
package com.mimacom.shapeservice;
import com.mimacom.shapeservice.controllers.ShapeController;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.Before;
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.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ShapeServiceApplication.class)
public class BaseClass {
@Autowired
ShapeController shapeController;
@Before
public void setup() {
RestAssuredMockMvc.standaloneSetup(shapeController);
}
@Test
public void contextLoads() {
}
}
Wait! What did we just do here?
Most of that was just configuration to get gradle to include the dependencies and execute contract tests as a matter of testing your application. Adding the BOM allows you to include the testCompile dependencies in the next step without version numbers and be assured that the versions will be compatible. The testCompile statements include the dependencies required during the test phase of app building. Adding spring-cloud-gradle-plugin to the build script classpath allows the plugin to be used. Applying the Spring Cloud Contract plugin enables the hooks that execute your contract tests. The contracts section is read by the Spring Cloud Contract plugin to configure execution of contract tests. Lastly we create a "BaseClass" that will be used to set up the environment and execute the contract tests.
Writing the Tests
Now place yourself in the position of the developer of a client of the shape-service. You need to write tests that define how you intend to use the service. You do this by checking out the service project and shape-service/src/test/resources/contracts
.
Create a file called shouldReturnAreaOfTriangle.groovy
in service/src/test/resources/contracts
and populate it with the following:
import org.springframework.cloud.contract.spec.Contract
[
Contract.make {
description "Should return the area of a 100x100 triangle"
request {
url "/triangle?length=100.0&width=100.0"
method GET()
}
response {
status 200
headers{
header 'Content-Type': 'application/json;charset=UTF-8'
}
body (
area: 5000
)
}
},
Contract.make {
description "Should return the area of a 100x50 triangle"
request {
url "/triangle?length=100.0&width=200.0"
method GET()
}
response {
status 200
headers{
header 'Content-Type': 'application/json;charset=UTF-8'
}
body (
area: 10000
)
}
},
Contract.make {
description "Should return the area of a 50x100 triangle"
request {
url "/triangle?length=50.0&width=100.0"
method GET()
}
response {
status 200
headers{
header 'Content-Type': 'application/json;charset=UTF-8'
}
body (
area: 2500
)
}
},
Contract.make {
description "Should return the area of a 50x50 triangle"
request {
url "/triangle?length=50.0&width=50.0"
method GET()
}
response {
status 200
headers{
header 'Content-Type': 'application/json;charset=UTF-8'
}
body (
area: 5000
)
}
}
]
This groovy script provides 4 test cases to calculation is correct. Lets go ahead and execute tests:
./gradlew clean shape-service:test
Oops! We had a failure, open shape-service/build/reports/tests/test/index.html
to see what went wrong:
Click on the validate_shouldReturnAreaOfTriangle_3 link to see the difference between the output and the expected result:
In this case, our test was incorrect and we need to change the last test to:
Contract.make {
description "Should return the area of a 50x50 triangle"
request {
url "/triangle?length=50.0&width=50.0"
method GET()
}
response {
status 200
headers{
header 'Content-Type': 'application/json;charset=UTF-8'
}
body (
area: 1250
)
}
}
Since the contract test are working correctly in the shape service, we need to publish the new version of the service. Execute ./gradlew clean build shape-service:install
to install the contract stubs in your local maven repository. As we'll see in the next section, the stubs can be used in place of a functioning instance of the service.
The Sign Service
Now that we have published stubs, we can use Spring Cloud Contract's stub runner to mock the service for tests. First add the testCompile dependencies to the dependencies section:
testCompile('org.springframework.cloud:spring-cloud-contract-wiremock')
testCompile('org.springframework.cloud:spring-cloud-starter-contract-stub-runner')
Next we update the tests to take advantage of the stubs. This involves adding a @Rule
for the stub runner by giving it the maven artifact, port, and stub mode (if you aren't pulling the stub from the central repository).
Updating the Tests
ShapeClientTest.java
becomes:
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class ShapeClientTest {
@Rule
public StubRunnerRule stubRunnerRule = new StubRunnerRule()
.downloadStub("com.mimacom", "shape-service")
.withPort(8082)
.stubsMode(StubRunnerProperties.StubsMode.LOCAL);
@Autowired
ShapeClient shapeClient;
@Test
public void getTriangle_integration_test() throws Exception {
Shape triangle = shapeClient.getTriangle(100d, 100d);
BDDAssertions.then(triangle.getArea()).isEqualTo(5000.0);
}
}
All we did here was add the rule and remove the @Ignore
.
We can clean up SignServiceTest.java
as well:
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class SignServiceTest {
@Rule
public StubRunnerRule stubRunnerRule = new StubRunnerRule()
.downloadStub("com.mimacom", "shape-service")
.withPort(8082)
.stubsMode(StubRunnerProperties.StubsMode.LOCAL);
@Autowired
SignService signService;
@Test
public void should_return_correct_price() throws Exception {
Double price = signService.getTrianglePriceForDimensions(.50, 100.0, 200.0);
BDDAssertions.then(price).isEqualTo(5000);
}
}
Here, we've added the rule and removed all of the explicit mocks.
Running the Test
You can run the tests for just the sign-service from the command line:
./gradlew clean sign-service:test
or you can run all of the tests:
./gradlew clean test
Conclusion
Implementing consumer driven contracts using Spring Cloud Contract is an excellent way to ensure that changes to your services do not break your consumers. Breaking changes can be identified and communicated before integration testing. Additionally, the generated stubs allow consumers to constantly and automatically mock out service calls.