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