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:

 


Leave a comment

Your email address will not be published.


*