It's been a while since we talked about testing and applying effective TDD practices, particularly related to REST(ful) web services and APIs. But this topic should have never been forgotten, especially in the world where everyone is doing microservices, whatever it means, implies or takes.
To be fair, there are quite a lot of areas where microservice-based architecture shines and allows organizations to move and innovate much faster. But without a proper discipline, it also makes our systems fragile, as they become very loosely coupled. In today's post we are going to talk about contract-based testing and consumer-driven contracts as a practical and reliable techniques to ensure that our microservices fulfill their promises.
So, how does contract-based testing work? In nutshell, it is surprisingly simple technique and is guided by following steps:
- provider (let say Service A) publishes its contact (or specification), the implementation may not even be available at this stage
- consumer (let say Service B) follows this contract (or specification) to implement conversations with Service A
- additionally, consumer introduces a test suite to verify its expectations regarding Service A contract fulfillment
Pact is family of frameworks for supporting consumer-driven contracts testing. There are many language bindings and implementations available, including JVM ones, JVM Pact and Scala-Pact. To evolve such a polyglot ecosystem, Pact also includes a dedicated specification so to provide interoperability between different implementations.
Great, Pact is there, the stage is set and we are ready to take off with some real code snippets. Let us assume we are developing a REST(ful) web API for managing people, using terrific Apache CXF and JAX-RS 2.0 specification. To keep things simple, we are going to introduce only two endpoints:
- POST /people/v1 to create new person
- GET /people/v1?email=<email> to find person by email address
@Api(value = "Manage people") @Path("/people/v1") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public class PeopleRestService { @GET @ApiOperation(value = "Find person by e-mail", notes = "Find person by e-mail", response = Person.class) @ApiResponses({ @ApiResponse(code = 404, message = "Person with such e-mail doesn't exists", response = GenericError.class) }) public Response findPerson( @ApiParam(value = "E-Mail address to lookup for", required = true) @QueryParam("email") final String email) { // implementation here } @POST @ApiOperation(value = "Create new person", notes = "Create new person", response = Person.class) @ApiResponses({ @ApiResponse(code = 201, message = "Person created successfully", response = Person.class), @ApiResponse(code = 409, message = "Person with such e-mail already exists", response = GenericError.class) }) public Response addPerson(@Context UriInfo uriInfo, @ApiParam(required = true) PersonUpdate person) { // implementation here } }
The implementation details are not important at the moment, however let us take a look at the GenericError, PersonUpdate and Person classes as they are an integral part of our service contract.
@ApiModel(description = "Generic error representation") public class GenericError { @ApiModelProperty(value = "Error message", required = true) private String message; } @ApiModel(description = "Person resource representation") public class PersonUpdate { @ApiModelProperty(value = "Person's first name", required = true) private String email; @ApiModelProperty(value = "Person's e-mail address", required = true) private String firstName; @ApiModelProperty(value = "Person's last name", required = true) private String lastName; @ApiModelProperty(value = "Person's age", required = true) private int age; } @ApiModel(description = "Person resource representation") public class Person extends PersonUpdate { @ApiModelProperty(value = "Person's identifier", required = true) private String id; }
Excellent! Once we have Swagger annotations in place and Apache CXF Swagger integration turned on, we could generate swagger.json specification file, bring it to live in Swagger UI and distribute to every partner or interested consumer.
Would be great if we could use this Swagger specification along with Pact framework implementation to serve as a service contract. Thanks to Atlassian, we are certainly able to do that using swagger-request-validator, a library for validating HTTP request/respons against a Swagger/OpenAPI specification which nicely integrates with Pact JVM as well.
Cool, now let us switch sides from provider to consumer and try to figure out what we can do having such Swagger specification in our hands. It turns out, we can do a lot of things. For example, let us take a look at the POST action, which creates new person. As a client (or consumer), we could express our expectations in such a form that having a valid payload submitted along with the request, we expect HTTP status code 201 to be returned by the provider and the response payload should contain a new person with identifier assigned. In fact, translating this statement into Pact JVM assertions is pretty straightforward.
@Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID) public PactFragment addPerson(PactDslWithProvider builder) { return builder .uponReceiving("POST new person") .method("POST") .path("/services/people/v1") .body( new PactDslJsonBody() .stringType("email") .stringType("firstName") .stringType("lastName") .numberType("age") ) .willRespondWith() .status(201) .matchHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) .body( new PactDslJsonBody() .uuid("id") .stringType("email") .stringType("firstName") .stringType("lastName") .numberType("age") ) .toFragment(); }
To trigger the contract verification process, we are going to use awesome JUnit and very popular REST Assured framework. But before that, let us clarify on what is PROVIDER_ID and CONSUMER_ID from the code snippet above. As you may expect, PROVIDER_ID is the reference to the contract specification. For simplicity, we would fetch Swagger specification from running PeopleRestService endpoint, luckily Spring Boot testing improvements make this task a no-brainer.
@RunWith(SpringJUnit4ClassRunner.class) @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = PeopleRestConfiguration.class) public class PeopleRestContractTest { private static final String PROVIDER_ID = "People Rest Service"; private static final String CONSUMER_ID = "People Rest Service Consumer"; private ValidatedPactProviderRule provider; @Value("${local.server.port}") private int port; @Rule public ValidatedPactProviderRule getValidatedPactProviderRule() { if (provider == null) { provider = new ValidatedPactProviderRule("http://localhost:" + port + "/services/swagger.json", null, PROVIDER_ID, this); } return provider; } }
The CONSUMER_ID is just a way to identify the consumer, not much to say about it. With that, we are ready to finish up with our first test case:
@Test @PactVerification(value = PROVIDER_ID, fragment = "addPerson") public void testAddPerson() { given() .contentType(ContentType.JSON) .body(new PersonUpdate("tom@smith.com", "Tom", "Smith", 60)) .post(provider.getConfig().url() + "/services/people/v1"); }
Awesome! As simple as that, just please notice the presence of @PactVerification annotation where we are referencing the appropriate verification fragment by name, in this case it points out to addPerson method we have introduced before.
Great, but ... what the point? Glad you are asking, because from now on any change in the contract which may not be backward compatible will break our test case. For example, if provider decides to remove the id property from the response payload, the test case will fail. Renaming the request payload properties, big no-no, again, test case will fail. Adding new path parameters? No luck, test case won't let it pass. You may go even further than that and fail on every contract change, even if it backward-compatible (using swagger-validator.properties for fine-tuning).
validation.response=ERROR validation.response.body.missing=ERROR
No a very good idea but still, if you need it, it is there. Similarly, let us add a couple of more test cases for GET endpoint, starting from successful scenario, where person we are looking for exists, for example:
@Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID) public PactFragment findPerson(PactDslWithProvider builder) { return builder .uponReceiving("GET find person") .method("GET") .path("/services/people/v1") .query("email=tom@smith.com") .willRespondWith() .status(200) .matchHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) .body( new PactDslJsonBody() .uuid("id") .stringType("email") .stringType("firstName") .stringType("lastName") .numberType("age") ) .toFragment(); } @Test @PactVerification(value = PROVIDER_ID, fragment = "findPerson") public void testFindPerson() { given() .contentType(ContentType.JSON) .queryParam("email", "tom@smith.com") .get(provider.getConfig().url() + "/services/people/v1"); }
Please take a note that here we introduced query string verification using query("email=tom@smith.com") assertion. Following the possible outcomes, let us also cover the unsuccessful scenario, where person does not exist and we expect some error to be returned, along with 404 status code, for example:
@Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID) public PactFragment findNonExistingPerson(PactDslWithProvider builder) { return builder .uponReceiving("GET find non-existing person") .method("GET") .path("/services/people/v1") .query("email=tom@smith.com") .willRespondWith() .status(404) .matchHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) .body(new PactDslJsonBody().stringType("message")) .toFragment(); } @Test @PactVerification(value = PROVIDER_ID, fragment = "findNonExistingPerson") public void testFindPersonWhichDoesNotExist() { given() .contentType(ContentType.JSON) .queryParam("email", "tom@smith.com") .get(provider.getConfig().url() + "/services/people/v1"); }
Really brilliant, maintainable, understandable and non-intrusive approach to address such a complex and important problems as contract-based testing and consumer-driven contracts. Hopefully, this somewhat new testing technique would help you to catch more issues during the development phase, way before they would have a chance to leak into production.
Thanks to Swagger we were able to take a few shortcuts, but in case you don't have such a luxury, Pact has quite rich specification which you are very welcome to learn and use. In any case, Pact JVM does a really great job in helping you out writing small and concise test cases.
The complete project sources are available on Github.
No comments:
Post a Comment