Consumer Driven Contract – testy kontraktowe w świecie mikroserwisów
Consumer Driven Contract – testy kontraktowe w świecie mikroserwisów
Consumer Driven Contract to testy jednostkowe (TDD) na poziomie architektury – pozwalają weryfikować komunikację pomiędzy komponentami w środowisku mikroserwisów. Jest to istotne ponieważ testując kod poprzez mockowanie komunikacji HTTP np. z użyciem bezpłatnej biblioteki WireMock to programista sam określa jak usługi które będą w aplikacji wykorzystywane będą się zachowywać. Może to doprowadzić do rozbieżności zachowania usług na produkcji z tym co zostało zasymulowane/zaślepione przez developera w trakcie realizacji prac nad oprogramowaniem. Podejście CDC – Consumer Driven Contract rozwiązuje ten problem:
Tworzymy przykładowe usługi:
@RestController @RequestMapping("/calculate") public class CalculatorController { @RequestMapping(value = "/addition/{number1}/{number2}", method = {RequestMethod.GET}, produces = "application/json") CalculatorModel addition(@PathVariable int number1, @PathVariable int number2){ return CalculatorModel.builder() .result(number1 + number2) .operation("addition") .build(); } @RequestMapping(value ="/subtract/{number1}/{number2}", method = {RequestMethod.GET},produces = "application/json") CalculatorModel subtract(@PathVariable int number1, @PathVariable int number2){ return CalculatorModel.builder() .result(number1 - number2) .operation("subtract") .build(); } @RequestMapping(value ="/multiply/{number1}/{number2}", method = {RequestMethod.GET},produces = "application/json") CalculatorModel multiply(@PathVariable int number1, @PathVariable int number2){ return CalculatorModel.builder() .result(number1 * number2) .operation("multiply") .build(); } @RequestMapping(value ="/division/{number1}/{number2}", method = {RequestMethod.GET},produces = "application/json") CalculatorModel division(@PathVariable int number1, @PathVariable int number2){ return CalculatorModel.builder() .result(number1 / number2) .operation("division") .build(); } } @Builder @Data class CalculatorModel { private int result; private String operation; }
Korzystam z biblioteki Lombok. W przypadku Intellij IDEA należy oprócz dodania zależności:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
dodać dodatkowo plugin IntelliJ Lombok plugin. Niezbędne zależności (korzystam ze Spring Boota w wersji 1.5.9.RELEASE):
<properties> <java.version>1.8</java.version> <spring-cloud.version>Dalston.SR4</spring-cloud.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId> spring-cloud-starter-contract-verifier </artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <!-- build stub --> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>1.1.4.RELEASE</version> <extensions>true</extensions> <configuration> <baseClassForTests> pl.javaleader.ConsumerDrivenContractProducer.BaseClass </baseClassForTests> </configuration> </plugin> </plugins> </build> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Kontrakty napisane w języku groovy:
./src/test/resources/contracts/
addition.groovy:
Contract.make { description "Should return addition numbers 2 and 3" request { method("GET") url("/calculate/addition/2/3") } response { status 200 body(["result":5, "operation": "addition"]) headers { contentType("application/json") } } }
subtract.groovy:
Contract.make { description "Should return subtract numbers 2 and 3" request { method("GET") url("/calculate/subtract/2/3") } response { status 200 body(["result":-1, "operation": "subtract"]) headers { contentType("application/json") } } }
multiply.groovy:
Contract.make { description "Should return multiply numbers 2 and 3" request { method("GET") url("/calculate/multiply/2/3") } response { status 200 body(["result":6, "operation": "multiply"]) headers { contentType("application/json") } } }
division.groovy:
Contract.make { description "Should return division numbers 10 and 2" request { method("GET") url("/calculate/division/10/2") } response { status 200 body(["result":5, "operation": "division"]) headers { contentType("application/json") } } }
Generujemy Stuby! Stuby to pliki JAR utworzone z użyciem pluginu spring-cloud-contract-maven-plugin umieszczane w lokalnym bądź zdalnym repozytorium Mavena do których dostęp mają różne zespoły budujące aplikację:
<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>1.1.4.RELEASE</version> <extensions>true</extensions> <configuration> <baseClassForTests> pl.javaleader.ConsumerDrivenContractProducer.BaseClass </baseClassForTests> </configuration> </plugin>
Powyższy plugin generuje również testy które weryfikują czy aplikacja działa zgodnie z ustalonym kontraktem. Dzięki temu nie dojdzie do sytuacji w której zespół utworzy sobie Stuba, wypuści go do repozytorium zdalnego Mavena i nie jest on zgodny z wcześniej uzgodnionym kontraktem. Należy utworzyć klasę bazową po której wygenerowana klasa testowa będzie dziedziczyć:
@RunWith(SpringRunner.class) @SpringBootTest(classes = ConsumerDrivenContractProducerApplication.class) public abstract class BaseClass { @Autowired CalculatorController calculatorController; @Before public void setUp() throws Exception { RestAssuredMockMvc.standaloneSetup(calculatorController); } }
Automatucznie wygenerowane testy:
./target/generate-test-sources/contracts/org.springframework.cloud.contract.verifier.tests/ContactVerifierTest.java
public class ContractVerifierTest extends BaseClass { @Test public void validate_addition() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/calculate/addition/2/3"); // 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("['result']").isEqualTo(5); assertThatJson(parsedJson).field("['operation']").isEqualTo("addition"); } @Test public void validate_division() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/calculate/division/10/2"); // 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("['result']").isEqualTo(5); assertThatJson(parsedJson).field("['operation']").isEqualTo("division"); } @Test public void validate_multiply() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/calculate/multiply/2/3"); // 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("['result']").isEqualTo(7); assertThatJson(parsedJson).field("['operation']").isEqualTo("multiply"); } @Test public void validate_subtract() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/calculate/subtract/2/3"); // 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("['operation']").isEqualTo("subtract"); assertThatJson(parsedJson).field("['result']").isEqualTo(-1); } }
Inny zespół integruje swój mikroserwis korzystając z usług innego mikroserwisu:
@RestController public class CalculatorController { private final RestTemplate restTemplate; public CalculatorController(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @GetMapping("/addition") ResponseEntity<String> addition(){ HttpHeaders headers = new HttpHeaders(); headers.set("Accept", MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange( "http://localhost:8080/calculate/addition/2/3", HttpMethod.GET, entity, String.class); } @GetMapping("/subtract") ResponseEntity<String> subtract(){ HttpHeaders headers = new HttpHeaders(); headers.set("Accept", MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange( "http://localhost:8080/calculate/subtract/2/3", HttpMethod.GET, entity, String.class); } @GetMapping("/multiply") ResponseEntity<String> multiply(){ HttpHeaders headers = new HttpHeaders(); headers.set("Accept", MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange( "http://localhost:8080/calculate/multiply/2/3", HttpMethod.GET, entity, String.class); } @GetMapping("/division") ResponseEntity<String> division(){ HttpHeaders headers = new HttpHeaders(); headers.set("Accept", MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange( "http://localhost:8080/calculate/division/10/2", HttpMethod.GET, entity, String.class); } }
Należy napisać testy które zweryfikują czy usługi działają poprawnie z wcześniej uzgodnionym kontraktem:
./src/test/java/consumer/pl.javaleader.ConsumerDrivenContractConsumer.consumer/ConsumerControllerIntegrationTest.java
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc @AutoConfigureStubRunner(workOffline = true, ids = { "pl.javaleader:Consumer-Driven-Contract-Producer"}) public class ConsumerControllerIntegrationTest { @Autowired MockMvc mockMvc; @Test public void addition() throws Exception { // When ResultActions result = mockMvc.perform(get("/addition").contentType((MediaType.APPLICATION_JSON))); // Then result.andExpect(status().isOk()) .andExpect(content().json( "{\"result\":5,\"operation\":\"addition\"}")); } @Test public void subtract() throws Exception { // When ResultActions result = mockMvc.perform(get("/subtract").contentType((MediaType.APPLICATION_JSON))); // Then result.andExpect(status().isOk()) .andExpect(content().json( "{\"result\":-1,\"operation\":\"subtract\"}")); } @Test public void multiply() throws Exception { // When ResultActions result = mockMvc.perform(get("/multiply").contentType((MediaType.APPLICATION_JSON))); // Then result.andExpect(status().isOk()) .andExpect(content().json( "{\"result\":6,\"operation\":\"multiply\"}")); } @Test public void division() throws Exception { // When ResultActions result = mockMvc.perform(get("/division").contentType((MediaType.APPLICATION_JSON))); // Then result.andExpect(status().isOk()) .andExpect(content().json( "{\"result\":5,\"operation\":\"division\"}")); } }
Adnotacja @AutoConfigureStubRunner:
ids – tablica wcześniej utworzonych stubów,
workOffline – wartość true oznacza, że stuby będą pobierane z lokalnego repozytorium Mavena. wartość false oznacza zdalne repozytorium np. Nexus, Apache Archiva.
Polecam dodatkowo obejrzeć prezentację na YouTube Łukasza Parczewskiego:
http://www.youtube.com/watch?v=zAxDcuBPAbw
Leave a comment