Using Spring Cloud Contract

March 29, 2018

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.

If it ain't broke, don't fix it

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:

Gradle Test Error

Click on the validate_shouldReturnAreaOfTriangle_3 link to see the difference between the output and the expected result:

Gradle Test Error

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.

Tell me what you want

About the author: William Witt