Zintegruj swoje mikroserwisy za pomocą Consumer-Driven Contract (CDC). Koniec z niedziałającymi testami integracyjnymi i środowiskiem testowym.
Aby uzyskać relatywnie dużą niezależność (niski coupling) i przy okazji wysoką kohezję naturalne jest dzielenie systemów na mniejsze części. Idealną sytuacją jest niezależność systemów i zespołów, które są ich właścicielami – release jednego systemu nie pociąga za sobą konieczności wdrożenia innego na zasadzie: razem, albo w ogóle. Jeżeli jest odwrotnie – istnieje wysoka zależność (coupling).
Gdy już podzielimy systemy, należy je ze sobą zintegrować, np. za pomocą REST, Message-Driven – nazwijmy z grubsza, że system wystawia jakieś API. Gdy dochodzi do integracji, każdy system po swojej stronie chce mieć pewność, że komunikacja jest zapewniona.
Dlatego zgodnie z piramidą testów pisane są testy na różnych poziomach: unitowe/komponentowe działające w izolacji i integracyjne generujące realny ruch i sprawdzające połączenie między systemami.
Z tego artykułu dowiesz się:
- Czym jest Consumer-Driven Contract (CDC) i jakie problemy rozwiązuje
- Czym jest Contract, Consumer i Provider
- Jak zaimplementować CDC za pomocą Spring Cloud Contract
Piguła wiedzy o najlepszych praktykach testowania w Java
Pobierz za darmo książkę 100 stron o technikach testowania w Java
Consumer i Provider API
Wprowadźmy dwa pojęcia:
- Consumer – konsument, system, który używa API.
- Provider – dostawca, system, który udostępnia swoje funkcjonalności poprzez API.
Patrząc z najpierw z perspektywy konsumenta – czyli zespołu, który używa API innego systemu – sytuacja może wyglądać mniej więcej w ten sposób:
W teście komponentowym utrzymujemy wysoką izolację – nie dochodzi do fizycznej integracji. Odpowiedź zastępujemy oczekiwaną odpowiedzią (stub → stałą, mock → sterowaną zachowaniem). Samodzielnie przechwytujemy jakąś przykładową odpowiedź (np. odpowiedź w formacje JSON/XML w pliku tekstowym dołączonym do zestawu testów) i wstawiamy ją do swojego systemu jako przykładową.
W teście integracyjnym istnieje niska izolacja – łączymy się po fizycznym protokole i testujemy scenariusze integracji. Potrzebny jest działający system Providera, na którym możemy sprawdzić nasz unit integracyjny.
Z kolei w teście end-to-end używamy aplikacji i sprawdzamy, czy działa ona całościowo, nie zwracając uwagi na szczegóły integracji. Proces ma ogólnie działać.
Rodzi się szereg problemów:
- Testy komponentowe są wyizolowane i niekoniecznie odzwierciedlają rzeczywisty stan odpowiedzi Providera ze względu na to, że używana jest pobrana próbka odpowiedzi. W międzyczasie kontrakt mógłby się zmienić bez zmiany przykładowej odpowiedzi w zestawie testów, które są zielone. Dlatego używane są testy integracyjne.
- Testy integracyjne są wolne i zależą od działającego Providera. W tym miejscu możemy zidentyfikować potencjalne problemy z kontraktem. Powoduje to, że żeby organizacja mogła przeprowadzać testy integracyjne, potrzebne jest działające, stabilne środowisko testowe przynajmniej dla każdego Providera.
- Gdy Provider zmieni swoje API naruszając czyjeś integracje – nie dowiaduje się o tym automatycznie.
Odnośnie stabilnego środowiska testowego: spójrzmy prawdzie w oczy. X zespołów robi „drobne zmiany” w swoich aplikacjach, które testują na środowisku testowym. Prawdopodobieństwo, że całe środowisko jest stabilne, wynosi iloczyn prawdopodobieństw: P(1) × P(2) × … × P(X). Prawdopodobieństwo maleje znacznie.
Jako Producer ustalmy kontrakt z Consumerem
Contract – kontrakt, czyli specyfikacja, na podstawie której zarówno Provider jak i Consumer opierają swoje implementacje.
Wcześniej wspomniane problemy zaadresować próbuje Consumer-Driven Contract. Idea jest prosta: wyspecyfikujmy API w formie dokumentu, wymieńmy się nim i każdy po swojej stronie uruchomi testy:
- Consumer po swojej strony uruchomi realnego mocka, który będzie odzwierciedlał zachowanie. Złamanie kontraktu po stronie integracji spowoduje przerwanie procesu budowania.
- Provider uruchomi zestaw testów kontraktowych i sprawdzi, czy je spełnia w sposób w pełni automatyczny jako część swoich testów. Złamanie kontraktu przez Providera API spowoduje przerwanie procesu budowania.
Zauważ, że metodologia nazywa się Consumer-Driven, czyli API wystawiane przez Providera jest skoncentrowane na aplikacjach, które będą z niego korzystać. Provider może mieć wiele kontraktów z Consumerami, którzy definiują swoje oczekiwania od usługi. Zmiana tego wektoru jest kluczowa, od teraz każda zmiana Providera jest automatycznie testowana przed wydaniem oprogramowania.
Wspomnę szereg zalet:
- Wymieniamy się zawsze najbardziej aktualnym kontraktem. To bardzo ważne, żeby żadna ze stron nie operowała na nieaktualnym kontrakcie.
- Każda ze stron wykonuje testy po swojej stronie w izolacji, ale na podstawie kontraktu. Nie jest wymagane działające w pełni zintegrowane środowisko.
- Consumer wykonuje szybkie wyizolowane testy po swojej stornie uruchamiając mocka sterowanego kontraktem.
- Provider w ramach swojego procesu budowania w sposób automatyczny dowie się, czy został złamany kontrakt z systemem.
Consumer-Driven Contract w praktyce
Przejdźmy do programowania, koncept jest już Ci znany. W Java na ten moment mamy co najmniej dwa bardziej popularne rozwiązania:
Moja osobista preferencja to Spring Cloud Contract ze względu na stos technologiczny, którego używam. Aby zaprezentować działanie na przykładzie, posłużę się domeną koszyka sklepowego.
Zdefiniujemy dwa REST endpointy:
POST /basket
Tworzy koszyk i zwraca kod201 Created
z body:{ basketId:"uuid-koszyka" }
POST /basket/uuid-koszyka/summary
Na podstawie podanych danych adresowych zwraca kod 200 i odpowiedź z ceną koszyka:{ totalPrice: 123.45 }
Zaprezentuję poniżej fragmenty kodu. Stworzyłem przykładowy projekt, którego pełne źródła znajdziesz pod adresem:
👉 https://github.com/softwareskill/cdc-example
Kod jest podzielony na dwa katalogi:
provider
(dostawca REST API) ,consumer
(klient REST API).
Producer
Producer definiuje kontrakty, wobec tego potrzebujemy dwóch zależności w pom.xml:
<dependencies> ... <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> ... <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>2.2.3.RELEASE</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> <baseClassForTests>pl.softwareskill.example.cdc.basket.rest.BaseContractTest</baseClassForTests> </configuration> </plugin> </plugins> </build>
Deklarujemy spring-cloud-starter-contract-verifier oraz spring-cloud-contract-maven-plugin – te zależności wygenerują zestaw testów po stornie producenta, które zostaną uruchomione w ramach scenariuszy testowych aplikacji, aby sprawdzić, czy spełnia ona wymagania kontraktu. BaseClassForTests
to klasa testu, która jest bazowa dla testów (w niej skonfigurujemy kontekst Spring`a oraz ewentualnie przygotujemy dane testowe pod kontrakty – o ile występują).
Następnie w katalogu /resources/contracts
definiujemy dwa kontrakty. Definiowanie kontraktów odbywa się poprzez użycie DSL (Domain-Specific Language) zdefiniowanego przez Spring Cloud Contract. Mogą być napisane w Groovy lub YAML.
Listing create-basket.groovy
package contracts org.springframework.cloud.contract.spec.Contract.make { request { method 'POST' url '/basket' //body() headers { contentType('application/json') } } response { status CREATED() body([ "basketId": $(anyUuid()) ]) headers { contentType('application/json') } } }
Listing basket-summary.groovy
package contracts org.springframework.cloud.contract.spec.Contract.make { request { method 'POST' url '/basket/063b360b-854a-4f5a-9081-fd0157135d9c/summary' body([ "shippingAddress": [ "country": "Polska", "city": "Warszawa", "street": "Podwale", "number": "12", ] ]) headers { contentType('application/json') } } response { status OK() body([ "totalPrice": $(anyNumber()) ]) headers { contentType('application/json') } } }
W kontraktach możesz zauważyć miejsca typu $(anyNumber())
lub $(anyUuid())
. Są to matchery (porównanie do wzorca), które będą wykorzystywane przy sprawdzeniu odpowiedzi. Pełną ich listę znajdziesz w dokumentacji.
Uruchamiamy testy i są czerwone. To dobrze, nasza aplikacja na razie nie spełnia wymagań.
Zerknijmy jednak w automatycznie wygenerowane testy przez Contract Verifier:
Powyższy screen prezentuje kod Java automatycznie wygenerowanych testów na podstawie DSL. Wykonywany jest request do aplikacji, a następnie sprawdzana jest odpowiedź (kod, content-type, zdefiniowane pola) – zgodnie z kontraktem.
Czas na implementację, tworzymy bazową klasę testów, która poprawnie skonfiguruje kontekst Spring’a i zarejestruje kontroler w bibliotece RestAssured używanej w Spring Cloud Contract:
Listing BaseContractTest.java
package pl.softwareskill.example.cdc.basket.rest; @SpringBootTest @AutoConfigureMockMvc public class BaseContractTest { @Autowired private MockMvc mockMvc; @BeforeEach public void setup() { RestAssuredMockMvc.mockMvc(mockMvc); } }
Czas na implementację kontrolerów:
RestConfig.java
package pl.softwareskill.example.cdc.basket.rest; import org.springframework.context.annotation.ComponentScan; import org.springframework.web.servlet.config.annotation.EnableWebMvc; @EnableWebMvc @ComponentScan(basePackages = "pl.softwareskill.example.cdc.basket.rest.controller") public class RestConfig { }
BasketController.java
package pl.softwareskill.example.cdc.basket.rest.controller; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import pl.softwareskill.example.cdc.basket.rest.dto.BasketSummaryDto; import pl.softwareskill.example.cdc.basket.rest.dto.BasketSummaryView; import pl.softwareskill.example.cdc.basket.rest.dto.BasketView; import java.math.BigDecimal; import java.net.URI; import java.util.UUID; @RestController @RequestMapping(path = "/basket") public class BasketController { @PostMapping public ResponseEntity<BasketView> createBasket() { // Basket basket = basketService.createBasket(); UUID basketId = UUID.randomUUID(); return ResponseEntity.created(URI.create("/" + basketId + "/summary")) .body(BasketView.builder() .basketId(basketId) .build()); } @PostMapping("/{basketId}/summary") public ResponseEntity<BasketSummaryView> summary(@PathVariable UUID basketId, @RequestBody BasketSummaryDto basketSummaryView) { BigDecimal summaryPrice = BigDecimal.TEN; return ResponseEntity.ok(BasketSummaryView.builder() .totalPrice(summaryPrice) .build()); } }
Uruchamiamy testy i tym razem są już zielone 👏. Spójrzmy na log ich wykonania. Testy preparują żądania do uruchomionej in-memory aplikacji i weryfikują, czy producer spełnia wymagania kontraktu.
Consumer
Pora skorzystać z REST API używając kontraktu. W projekcie konsumenta definiujemy zależność stub-runner, która uruchomi realnie działający proces symulujący Providera na podstawie wymienionego kontraktu.
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency>
Aby skomunikować się z REST API użyjemy przykładowo webflux, ale może to być dowolna biblioteka do wykonywania żądań HTTP.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>
Następnie piszemy kod kliencki, klasę BasketService
, która skorzysta z REST API i stworzy koszyk zakupowy:
package pl.softwareskill.example.cdc.consumer; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.WebClient; import java.util.UUID; @RequiredArgsConstructor public class BasketService { private final String basketServiceUrl; public UUID createBasket() { return WebClient.create(basketServiceUrl) .post() .uri("/basket") .contentType(MediaType.APPLICATION_JSON) .retrieve() .bodyToMono(BasketView.class) .block() .getBasketId(); } }
Świetnie, teraz należy pokryć użycie testem. Test zrobi następujące rzeczy:
- Za pomocą Stub Runnera uruchomi osobny proces WireMock (na losowym porcie), który będzie odpowiadał zgodnie ze zdefiniowanym kontraktem.
- Pobierze najświeższą wersję kontraktu.
package pl.softwareskill.example.cdc.consumer; @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment= WebEnvironment.NONE, classes = BasketServiceTest.TestConfig.class) @AutoConfigureStubRunner(ids = {"pl.softwareskill.example.cdc:provider"}, stubsMode = StubRunnerProperties.StubsMode.LOCAL) class BasketServiceTest { @Autowired BasketService basketService; @Test void createsNewBasket() { UUID newBasketId = basketService.createBasket(); assertThat(newBasketId) .isNotNull(); } @Configuration static class TestConfig { @StubRunnerPort("pl.softwareskill.example.cdc:provider") int basketServicePort; @Bean BasketService basketService() { return new BasketService("http://localhost:" + basketServicePort); } } }
Do powyższego kodu kilka komentarzy:
@AutoConfigureStubRunner
posiada parametr id, który określa koordynaty artefaktu, w którym znajdują się kontrakty, które zostaną automatycznie pobrane@StubRunnerPort("pl.softwareskill.example.cdc:provider")
zwraca port, dla którego został uruchomiony WireMock, na jego podstawie budowany jest URL do symulowanego REST API.
Chciałbym tutaj zaznaczyć, że losowanie wolnego portu jest bardzo dobrą praktyką, ponieważ:
- Buildy Continuous Integration są bardziej stabilne, wybrany port nie jest zajęty.
- Możemy uruchamiać kilka buildów jednocześnie – port nie jest zajmowany pomiędzy jobami Continuous Integration.
Zaglądając w logi testów, widzimy, że nasz kod wygenerował request do WireMocka, który odpowiedział zgodnie z kontraktem. Dzięki temu możemy sprawdzić kod Consumera, czy właściwie korzysta z API.
Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.
Więcej o Spring Cloud Contract
Garść informacji o Spring Cloud Contract:
- Projekt jest stale rozwijany, warto obserwować kolejne wydania
- Wspomaga CDC nie tylko dla REST API, ale również Messaging’u (np. JMS, EMS, Kafka, RabbitMQ)
- Kontrakty można definiować w m.in. YAML, Groovy, Pact oraz w Java
Linki:
- https://github.com/softwareskill/cdc-example – repozytorium z przykadowym projektem zaprezentowanym w artykule
- Spring Cloud Contract
Piguła wiedzy o najlepszych praktykach testowania w Java
Pobierz za darmo książkę 100 stron o technikach testowania w Java
Gdybyś potrzebował jeszcze więcej:
Jesteś Java Developerem?
Przejdź na wyższy poziom wiedzy
„Droga do Seniora” 🔥💪
Jesteś Team Leaderem? Masz zespół?
Podnieś efektywność i wiedzę swojego zespołu 👌
Podsumowanie
Consumer-Driven Contract to technika umożliwiająca wymienienie się kontraktem pomiędzy Consumerem i Producerem, zebranie wymagań każdego z nich i uruchamianie testów po obu stronach. Spring Cloud Contract to implementacja CDC, która pozwala na uruchomienie testów w izolacji. Pozwala to na spójne, zautomatyzowane przetestowanie założeń po obu stronach bez konieczności fizycznej integracji (posiadania uruchomionego środowiska testowego).
Provider to dostawca API, a Consumer to klient korzystający API.
Obecnie na rynku są dostępne co najmniej dwa popularne rozwiązania: Spring Cloud Contract i Pact.
Obraz: Tło plik wektorowy utworzone przez iconicbestiary – pl.freepik.com