Spring Cloud Contract – testy kontraktowe

pater.dev 4 lat temu

Żałuję, iż nie znałem tego wcześniej. Jeden projekt przy którym pracowałem aż prosił się o zapewnienie takiej stabilności komunikacji jaką dają testy kontraktowe.

Po co nam testy kontraktowe?

Mało jest systemów, które są w pełni autonomiczne. Bardzo często projekt przy którym pracujemy jest jakąś częścią całego procesu biznesowego. Wymusza to, abyśmy w jakiś sposób komunikowali się z pozostałymi systemami. I jak to zwykle bywa – spiąć się z innym systemem jest stosunkowo łatwo (zazwyczaj), ale utrzymać tę integrację stabilną…

Wspomniałem na początku o jednym projekcie przy którym pracowałem, gdzie testy kontraktowe idealnie by się nadawały. Tamtejszy system opierał się w znacznym stopniu na usługach dostarczonych przez inne zespoły. Komunikacja między zespołami była, delikatnie mówiąc, słaba. Pomijam czym to było spowodowane. Wydaje mi się, iż nie zawsze mieli świadomość, iż jest ktoś tam jeszcze na końcu całego łańcucha pokarmowego.

Zdarzało się, iż zespół, który dostarczał nam usługę, zmieniał strukturę API. My o tym nie wiedzieliśmy. Do czasu, aż nasza aplikacja nie wysypała się na środowisku testowym przy testach regresyjnych. Trzeba było przejść całą ścieżkę zdrowia: doproszenie się logów od administratorów, oczekiwanie na kolejne okienko wdrożeniowe (zazwyczaj dopiero następnego dnia). Dzień stracony, bo testerzy czekają na poprawkę, testy regresyjne za niedługo powinny się kończyć… Dużo niepotrzebnych nerwów.

A gdyby tak o niespójności naszej komunikacji z innymi systemami dowiadywać się w trakcie uruchamiania testów na środowisku lokalnym?

Testy end to end

Teoretycznie możemy to uzyskać dzięki testów end-to-end. Jednak ten rodzaj testów ma kilka podstawowych wad:

  1. Potrzebujemy odwzorować całe środowisko – często jest to niemożliwe i gdzieś muszą być te zaślepki (stuby/mocki).
  2. Są czasochłonne – uruchomienie takich testów może trwać bardzo długo. Niektóre procesy realizowane są kilka/kilkanaście godzin.
  3. Problem z analizowaniem błędów (debug).

A chcemy mieć tylko pewność, iż nasz kontrakt z usługami z którymi się komunikujemy się nie zmienił. Tylko tyle na tym etapie.

Testy integracyjne z mockami

Możemy zamockować wszystkie nasze zewnętrzne systemy chociażby wiremockiem. Dzięki temu przynajmniej 2 i 3 problem związany z testami end-to-end się rozwiązuje.

Ale taki sposób testowania nie zabezpieczy nas przed zmianą kontraktu po stronie usługi z którą się komunikujemy. Przecież o ile nikt nas nie powiadomi, aby w naszym wiremocku zmienić odpowiedzi usługi to nasze testy dalej mogą być zielone na środowisku lokalnym. Na testowym ich barwa może się zmienić.

Zaślepki usługi dostarczone przez właściciela usługi

W przypadku testów end-to-end ciężko zniwelować wadę czasochłonności, ale możemy spróbować podrasować podejście drugie.

Załóżmy, iż zrzucimy odpowiedzialność utrzymania takiej zaślepki systemu zespołowi, który jest odpowiedzialny za dany system. Zespół powinien zaktualizować odpowiedzi w wiremocku przy każdej zmianie kontraktu i wydać zainteresowanym (klientom usługi) np. w formie biblioteki. My, jako klienci danej usługi, będziemy zaciągać zawsze najnowszą wersję tej biblioteki i będziemy wykorzystywać ją w naszych testach.

Nasuwa się jednak kilka wad takiego rozwiązania:

  1. Co o ile zespół zapomni zaktualizować stuby?
  2. Co o ile zespół zapomni nas poinformować, iż zaktualizował stuby?

Są one niestety nieakceptowalne z jednego głównego powodu – przerzucamy odpowiedzialność za stabilności naszego systemu na inny zespół.

Gdyby jednak udało się zrobić tak, aby dany zespół był zmuszony do zaktualizowania stubów i poinformowania nas o tym, bo w przeciwnym wypadku nie uda mu się choćby wydać nowej wersji aplikacji. Brzmi nieźle?

No i właśnie dotarliśmy do tego czym jest Spring Cloud Contract.

Testy kontraktowe

W skrócie:

  1. Producent (właściciel systemu, który dostarcza API) na podstawie zdefiniowanych kontraktów (zakładany request i response) podczas kompilowania systemu generuje testy, które sprawdzają zgodność API z tymi właśnie kontraktami.
  2. Jeżeli API się zmieni, a kontrakt będzie nieaktualny to aplikacja nie zbuduje się.
  3. Na podstawie kontraktów generowana jest biblioteka ze stubami z której korzystać mogą klienci API do pisania testów integracyjnych.

Ciekawostka: biblioteka Spring Cloud Contract (wcześniej nazywała się Accurest) została napisana przez Polaków – Jakuba Kubryńskiego i Marcina Grzejszczaka.

Po tym teoretycznym wstępie czas na trochę kodu.

Producent

Dorzucamy spring-cloud-contract do naszych zależności:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency>

Oraz plugin:

<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>2.2.1.RELEASE</version> <extensions>true</extensions> <configuration> <baseClassForTests>package.BaseTestClass</baseClassForTests> <testFramework>JUNIT5</testFramework> </configuration> </plugin>

Tak jak wspomniałem wcześniej – aby producent API nie wydał aplikacji bez zaktualizowanych kontraktów wymagane są testy, które nie pozwolona na zbudowanie aplikacji. Te testy mogą być wygenerowane zarówno w JUNIT, w Spocku jak i kilku innych frameworkach.

baseClassForTests to namiar na klasę po której dziedziczyć będą wygenerowane testy. Możemy dzięki temu odizolować nasze kontrolery, aby móc testować jedynie strukturę ich odpowiedzi.

Przykładowa, najprostsza klasa:

@ExtendWith(MockitoExtension.class) public abstract class BaseTestClass { @InjectMocks AccountNumberRestController accountNumberRestController; @BeforeEach public void setUp() { RestAssuredMockMvc.standaloneSetup(this.accountNumberRestController); } }

Tutaj jest na prawdę sporo możliwości. Można pomockować serwis i zrealizować najróżniejsze scenariusze odpowiedzi. Jak przy standardowym mockowaniu. Więcej również tutaj.

Definiujemy przykładowy kontroler z dwoma endpointami:

  1. GET: Do pobrania accountNumber
  2. PUT: Do zaktualizowania accountNumber z prostym warunkiem na sprawdzenie poprawności danych wejściowych.
@RestController @RequestMapping("/accountNumber") public class AccountNumberRestController { @GetMapping public ResponseEntity<AccountNumber> get() { return ResponseEntity.ok().body(new AccountNumber(123L)); } @PutMapping(produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity put(@RequestBody AccountNumber accountNumber) { if (accountNumber.getAccountNumber() < 10) return ResponseEntity.badRequest() .body(new ErrorMessage("ACCOUNT_NUMBER_INVALID", "Account number too short")); // logic return ResponseEntity.ok().build(); } }

I na końcu nasze kontrakty! Standardowo powinny one się znaleźć w katalogu: test/resources/contracts/. Jest to oczywiście konfigurowalne w pom.xml. W tym katalogu już możemy robić sobie dowolne podkatalogi, aby mieć to w miarę uporządkowane.

Kontrakty można pisać w groovy oraz yaml. Sam wybrałem groovy. Yaml wydaje się tutaj trochę masochistycznym rozwiązaniem.

Pierwszy kontrakt GetAccountNumberContract.groovy

package contracts.accountNumber import org.springframework.cloud.contract.spec.Contract; Contract.make { description(""" User asks for his account number ``` given: any client when: he asks for account number then: we'll send him back his account number ``` """) request { method 'GET' url '/accountNumber' headers { contentType(applicationJson()) } } response { status 200 body( accountNumber: anyNumber() ) headers { contentType(applicationJson()) } } }

Jak widać – prosta konstrukcja. Mamy podany request, mamy podany oczekiwany response. Dorzućmy jeszcze kolejny. Tym razem chcemy zaktualizowac accountNumber podając niepoprawną liczbę z zamiarem otrzymania zdefiniowanego błędu.

package contracts.accountNumber import org.springframework.cloud.contract.spec.Contract; Contract.make { description(""" User want to update his account number and passes invalid number ``` given: any client and too short account number when: he want to update his account number then: we'll send him error message ``` """) request { method 'PUT' url '/accountNumber' body( accountNumber: $(regex('[0-9]{1}')) ) headers { contentType(applicationJson()) } } response { status BAD_REQUEST() body([ errorCode: "ACCOUNT_NUMBER_INVALID", message: anyNonEmptyString() ]) headers { contentType(applicationJson()) } } }

Efekt zobaczymy po zbudowaniu:

mvn clean install

W target/generated-test-sources/contracts/ znajdziemy wygenerowany test we frameworku, który wybraliśmy. Jak widzimy – test ten dziedziczy po naszej klasie BaseTestClass

public class AccountNumberTest extends BaseTestClass { @Test public void validate_errorCheckAccountNumberContract() throws Exception { // given: MockMvcRequestSpecification request = given() .header("Content-Type", "application/json") .body("{\"accountNumber\":\"4\"}"); // when: ResponseOptions response = given().spec(request) .put("/accountNumber"); // then: assertThat(response.statusCode()).isEqualTo(400); assertThat(response.header("Content-Type")).matches("application/json.*"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).field("['errorCode']").isEqualTo("ACCOUNT_NUMBER_INVALID"); assertThatJson(parsedJson).field("['message']").matches("[\\S\\s]+"); } @Test public void validate_getAccountNumberContract() throws Exception { // given: MockMvcRequestSpecification request = given() .header("Content-Type", "application/json"); // when: ResponseOptions response = given().spec(request) .get("/accountNumber"); // 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("['accountNumber']").matches("-?(\\d*\\.\\d+|\\d+)"); } }

Spróbujmy teraz złamać nasz kontrakt. Od dzisiaj accountNumber to po prostu number. Próba zbudowania projektu zakończy się wysypaniem testów. Super!

Ale to nie koniec. o ile projekt się skompiluje i jest zgodny z kontraktem to wygenerowany zostanie również jar dla naszych klientów:

Możemy go teraz udostępnić w naszym firmowym Nexusie (bądź innym repozytorium) do którego dostęp mają klienci API.

Konsument

Po stronie konsumenta jest trochę łatwiej. Dorzucamy zależność:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

Mamy prosty serwis do komunikacji:

class WebService { ResponseEntity<MyAccountNumber> getAccountNumber() { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); return restTemplate .exchange("http://localhost:6565/accountNumber", GET, new HttpEntity<>(headers), MyAccountNumber.class); } HttpStatus updateAccountNumber(MyAccountNumber myAccountNumber) { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); try { restTemplate.put("http://localhost:6565/accountNumber", new HttpEntity<>(myAccountNumber, headers)); } catch (HttpClientErrorException ex) { return ex.getStatusCode(); } return HttpStatus.ACCEPTED; } }

Oraz testy, które opierają się na stubach dostarczonych przez producenta. W momencie uruchamiania aplikacji wstaje wiremock, który na porcie 6565 będzie odpowiadał na nasze zapytania według zdefiniowanych kontraktów.

@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @AutoConfigureStubRunner(ids = {"dev.pater:spring-cloud-contract-producer:+:stubs:6565"}, stubsMode = StubRunnerProperties.StubsMode.LOCAL) public class WebServiceTest { @Test public void testGet() { WebService webService = new WebService(); ResponseEntity<MyAccountNumber> myAccountNumber = webService.getAccountNumber(); Assert.assertEquals(myAccountNumber.getStatusCode(), HttpStatus.OK); } @Test public void testPut() { WebService webService = new WebService(); HttpStatus httpStatus = webService.updateAccountNumber(new MyAccountNumber(5)); Assert.assertEquals(httpStatus, HttpStatus.BAD_REQUEST); } }

Jak widzicie – pojawiło się dosyć sporo adnotacji. Nie będę ich wszystkich omawiał – odeśle Was pod ten adres gdzie jest to jasno opisane (wyszukaj frazę: @AutoConfigureStubRunner).

I teraz wróćmy do podstawowego scenariusza – coś się zmienia po stronie producenta. Producent dostarcza nowe kontrakty. Wracamy do przypadku, iż producent uznał za konieczne zmianę accountNumber na number.

W naszym kontrakcie zmieni się tylko response:

response { status 200 body( number: anyNumber() ) headers { contentType(applicationJson()) } }

Producent dostarczył nową bibliotekę. Przychodzimy do pracy, grzebiemy w projekcie, uruchamiamy testy i…

Testy wysypują się:

Co prawda GET się powiódł (bo w ogóle nie testowaliśmy struktury, a jedynie istniała asercja na to, aby odpowiedź miała status OK), ale PUT dał nam informację, iż coś się zmieniło.

I nasuwać się może pytanie w tym miejscu: skoro nie aktualizowaliśmy zależności projektu to jakim cudem zaciągnięta została nowa wersja biblioteki ze stubami? Odpowiedź tutaj:

@AutoConfigureStubRunner(ids = {"dev.pater:spring-cloud-contract-producer:+:stubs:6565"},

Ten plusik po środku informuje o tym, iż zawsze oczekujemy najnowszej wersji biblioteki ze stubami.

o.s.c.c.stubrunner.AetherStubDownloader : Remote repos not passed but the switch to work offline was set. Stubs will be used from your local Maven repository. o.s.c.c.stubrunner.AetherStubDownloader : Desired version is [+] - will try to resolve the latest version o.s.c.c.stubrunner.AetherStubDownloader : Resolved version is [0.0.2-SNAPSHOT]

Consumer Driven Contract

Cała ta koncepcja to takie trochę TDD, ale na poziomie architektury. Super sprawą jest, iż możemy rzeczywiście zdefiniować ten kontrakt, wypuścić zaślepki i dopiero zająć się implementacją. Dzięki czemu nie blokujemy pozostałych zespołów, które oczekują na API. o ile wprowadzamy jakieś zmiany to możemy wypuścić stuby np. wersję wcześniej do repozytorium. Zespoły, które korzystają z API dostaną informację (w najlepszej postaci jaką można sobie wymarzyć – wywalonych testów na lokalnym środowisku) i czas na dostosowanie się do nowszej wersji API.

Kod aplikacji wykorzystanej we wpisie:

Dodatkowe źródła:

  1. Repozytorium z ciekawymi przykładami: https://github.com/spring-cloud-samples/spring-cloud-contract-samples/
  2. Dokumentacja na stronie springa: https://cloud.spring.io/spring-cloud-contract/reference/html/getting-started.html
  3. Projekt na stronie springa: https://spring.io/projects/spring-cloud-contract

Kilka ciekawych prezentacji:

Idź do oryginalnego materiału