Monoliths vs Microservices
In a monolithic application we don’t need to worry about breaking contracts between different services because thecompiler will do that job for us. If a given method signature changes, the contract between shared services is broken,and the build will automatically fail.
In a microservices approach, different services are deployed in different runtimes and don’t know anything about eachother. We don’t have the compiler to detect those breaking changes and they became hard to detect and manage. Usually,they’re found during end-to-end testing in a pre-production environment.
Integration testing
When performing integration tests on services that depends on other external services, we tend to build our own stubs toassert the answers that we need in order to successfully run the test suite, not the answers that the producer servicemight deliver. By running integration tests against these predefined answers, it can lead to passing tests within theconsumer service boundary and failing tests in a pre-production environment.
End-to-end testing
These tests are designed to test the full application from top to bottom by simulating a real scenario. Despite beingvery useful in order to verify that a given scenario is working as expected across multiple applications, they tend tobe very hard to write and maintain, slow to execute, and usually demanding of a dedicated pre-production environment.They also provide very late feedback, usually being the last tests being implemented.
Common Contract Breaking Scenarios
- Renaming endpoints Adding new mandatory parameters Removing existing parameters Changing validations of existing parameters Changing the response types or status code
Contracts Testing
“An integration contract test is a test at theboundary of an external service verifying that itmeets the contract expected by a consuming service”
— Martin Fowler
A contract is a set of expectations shared between a service that acts as a consumer and another service that acts likea producer. They focus the specification and delivery of service functionality around key business value drivers. Thecompatibility of a contract remains stable and immutable for a particular set of consumer contracts and expectations.
Spring Cloud Contract
Provides support for Consumer Driven Contracts and service schemas in Spring applications, covering a range of optionsfor writing tests, publishing them as assets, asserting that a contract is kept by producers and consumers, for HTTPandmessage-based interactions.
Consumer-Driven Contracts Git Flow
Defining the Contract
As a consumer we need to exactly define our expectations. In this case, when sending a request from the consumer to theproducer, we want a successful response that matches with our request. Remember that the purpose of contract testing isnot to start writing business features in the contracts. Stay focused and limit yourself to testing contracts betweenapplications and avoid full behaviour simulation.
Contract.make { request { method "POST" url "/validate" body([ size: "SMALL" ]) headers { contentType(applicationJson()) } } response { status 200 body([ message: "Size is valid." ]) headers { contentType(applicationJson()) } }}Producer Test Generation
As a producer, the goal is to implement a feature that matches with the defined contract. In order to ensure that theapplication behaves in the same way as we defined above, an acceptance test is generated and it will run on every build,providing the feedback that we’re chasing. The test calls the /validate endpoint with body {"size":"SMALL"} and runthe assertions from the request section of our contract.
@Testpublic void validate_validSizeShouldReturnHttpOk() throws Exception { // given: MockMvcRequestSpecification request = given() .header("Content-Type", "application/json") .body("{\"size\":\"SMALL\"}"); // when: ResponseOptions response = given().spec(request) .post("/validate"); // 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("['message']") .isEqualTo("Size is valid.");}If we change the endpoint from /validate to something like /item/{uuid}/validate or change the response code from200 to 400, the test will fail. These kind of testing prevents us from, silently, breaking changes on the consumerside.
Stub Generation and Consumer Testing
As a consumer, the goal is to perform tests against the defined contract. In order to be able to perform those tests, aWireMock stub is also generated.
WireMock is an HTTP mock server. At its core it is web server that can be primed to serve canned responses toparticular requests (stubbing) and that captures incoming requests so that they can be checked later (verification).
The WireMock instance that is simulating the producer, will expose this stub every time that we trigger the /validateendpoint.
{ "id": "4ae4d36d-3c8a-4f06-b9d2-215f73cc9f10", "request": { "url": "/validate", "method": "POST", "headers": { "Content-Type": { "matches": "application/json.*" } }, "bodyPatterns": [ { "matchesJsonPath": "$[?(@.['size'] == 'SMALL')]" } ] }, "response": { "status": 200, "body": "{\"message\":\"Size is valid.\"}", "headers": { "Content-Type": "application/json" }, "transformers": [ "response-template" ] }, "uuid": "4ae4d36d-3c8a-4f06-b9d2-215f73cc9f10"}Our testing class should look like this:
@RunWith(SpringRunner.class)@SpringBootTest(webEnvironment = MOCK)@AutoConfigureMockMvc@AutoConfigureStubRunner(workOffline = true, ids = "me.ordepdev.contracts:+:stubs:8080")public class ConsumerTest { @Test public void validate_withValidSize_shouldReturnHttpOk() throws Exception { HttpHeaders headers = new HttpHeaders(); headers.add("Content-Type", "application/json"); ResponseEntity<Response> response = restTemplate.exchange( "http://localhost:8080/validate", HttpMethod.POST, new HttpEntity<>("{\"size\":\"SMALL\"}", headers), Response.class ); assertThat(response.getStatusCode()) .isEqualByComparingTo(HttpStatus.OK); assertThat(response.getBody().toString()) .isEqualTo("{\"message\":\"Size is valid.\"}"); }}This test is responsible for making a request to our producer endpoint http://localhost:8080/validate and assert thatthe response is valid. Notice that we don’t need to start up our producer service thanks to @AutoConfigureStubRunner.This annotation is responsible to start up a WireMock server at port 8080 with the stubs from the latest version ofme.ordepdev.contracts package. If the contract is changed, our consumer side test that rely on it, will fail. That’sthe beauty of gluing these pieces together: all contract changes leads to failed builds during development.
Working online
Note that when the flag workOffline is set to true, it assumes that you have the contract stubs installed on yourlocal maven repository. Since we’re working based on a consumer-driven git flow, when the proposed contracts get merged,this flag must be set to false in order to download the contracts from the remote maven repository.
Why contract testing matters?
This approach gives you the ability to always test against a synced and shared contract between producer andconsumer, instead of testing against exclusively consumer stubs, that may always differ from the producer ones.With a failing fast approach, you’ll be always able to catch breaking changes during development phase.
Resources
[1] https://cloud.spring.io/spring-cloud-contract
[2] https://github.com/spring-cloud/spring-cloud-contract
[4] https://martinfowler.com/articles/consumerDrivenContracts.html
