Przejdź do:
Czym jest Pact Consumer Driven Contract Testing
Consumer Driven Contract Testing to wzorzec wykorzystywany w testowaniu kompatybilności pomiędzy konsumentami (consumer) i dostawcami (provider) serwisów. Ogólna idea jest taka, że pomiędzy tymi dwoma systemami istnieje kontrakt opisujący zachodzące między nimi interakcje. Jeżeli obydwa systemy wypełniają jego założenia, test kończy się powodzeniem. Podejście „Consumer driven” oznacza, że siłą napędową ewolucji kontraktu są konsumenci serwisu, a nie dostawca.
Wsparciem dla Consumer Contract Testing jest zestaw frameworków DiUS Pact. Frameworki te koncentrują się głównie na komunikacji HTTP, choć dla niektórych platform dostępne jest również wsparcie dla kolejek. Obecnie implementacje pokrywają większość popularnych środowisk uruchomieniowych, takich jak JVM, .NET, JavaScript czy Ruby.
Czym jest Spring WebFlux
Spring WebFlux to reaktywny framework webowy wprowadzony w Springu 5. Jest to odpowiednik dobrze znanego Spring MVC, z tą różnicą, że opiera się na nieblokującym API dostarczanym przez reactive streams. WebFlux jest w stanie obsługiwać duże obciążenia na małej liczbie wątków, co skutkuje mniejszym zużyciem zasobów sprzętowych.
WebFlux oferuje dwa modele programowania:
- adnotowane kontrolery
- endpointy funkcyjne
Pact obsługuje oba modele, natomiast dla potrzeb tego artykułu skupię się na endpointach funkcyjnych.
Pact i WebFlux – jak to działa razem
Ponieważ to na kliencie spoczywa odpowiedzialność za dostarczanie kontraktu, testy Pact pisane są najpierw dla niego. Każdy z testów zawiera opis interakcji pomiędzy klientem i dostawcą, np. requesty i spodziewane odpowiedzi HTTP. Mając te dane, pact framework uruchomi tymczasowy serwer HTTP zwracający predefiniowane odpowiedzi i wykona testy klienta.
Niejako efektem ubocznym wykonania testu jest utworzenie pliku kontraktu. Ten sam plik zostanie później użyty do sprawdzenia, czy dostawca serwisu spełnia założenia kontraktu. Po stronie serwera Pact wykona testy, używając requestów zapisanych w kontrakcie i zweryfikuje poprawność odpowiedzi. Możemy tutaj dokonać wyboru, czy chcemy przetestować serwis jako całość, czy tylko jego wycinek odpowiedzialny za komunikację HTTP. W tym artykule opiszę ten drugi przypadek.
Test dla klienta
Załóżmy, że dostawca udostępnia endpoint HTTP. Odpowiada on na żądania HTTP GET, zwracając JSONa reprezentującego kolekcję obiektów Foo. Użyjmy frameworka Spock do zakodowania pact-testu klienta.
Najpierw zdefiniujmy pakt w sekcji given naszego testu. Wykorzystajmy do tego celu klasę ConsumerPactBuilder, którą udostępnia biblioteka Pact:
given: def pact = ConsumerPactBuilder.consumer("consumerService") .hasPactWith("providerService") .uponReceiving("sample request") .method("GET") .path("/foo") .willRespondWith() .status(200) .headers(["Content-Type": "application/json"]) .body(""" [ {"id": 1, "name": "Foo"}, {"id": 2, "name": "Bar"} ] """.stripIndent()) .toPact()
Widzimy tutaj, że kontakt definiuje interakcje nazwaną sample request pomiędzy klientem o nazwie consumerService i dostawcą providerService. W reakcji na żądanie będące wywołaniem metody HTTP GET na ścieżce /foo, dostawca powinien odpowiedzieć statusem HTTP 200, a w sekcji body odpowiedzi powinny być dostarczone reprezentacje dwóch obiektów Foo.
Zakodujmy teraz część z asercjami oraz wywołaniem naszego klienta w sekcji when:
when: def result = ConsumerPactRunnerKt.runConsumerTest( pact, MockProviderConfig.createDefault()) { mockServer, context -> def webClient = WebClient.create(mockServer.getUrl()) def consumerAdapter = new ConsumerAdapter(webClient) def resultFlux = consumerAdapter.invokeProvider() StepVerifier.create(resultFlux) .expectNext(new Foo(1l, 'Foo')) .expectNext(new Foo(2l, 'Bar')) .verifyComplete() }
Do wykonania testu klienta użyjemy klasy ConsumerPactRunnerKt dostarczanej przez bibliotekę Pact. Metoda runConsumerTest, przed wykonaniem kodu z domknięcia, uruchomi tymczasowy serwer, który będzie odpowiadać na żądania HTTP zdefiniowane wcześniej w pakcie. Zapisze również kontrakt w postaci pliku JSON, który będzie współdzielony z dostawcą usługi. Ostatnim z parametrów tej metody będzie blok kodu zawierający wywołanie napisanego przez nas klienta serwisu. W tym przypadku utworzymy reaktywnego webClienta i przekażemy mu URL utworzonego przez Pact serwera HTTP. Następnie stworzymy instancję naszego adaptera (ConsumerAdapter), na której wywołamy metodę invokeProvider() odpowiedzialną za interakcję HTTP z dostawcą. Ponieważ wynikiem tej interakcji będzie Flux do zbadania jej poprawności, możemy użyć StepVerifiera z projektu Reactor.
Ostatnią fazą będzie sprawdzenie w sekcji then, czy weryfikacja kontraktu się powiodła:
then: result instanceof PactVerificationResult.Ok
To właśnie tutaj możemy sprawdzić, czy StepVerifier nie wygenerował wyjątku (będzie on owinięty w klasie PactVerificationResult.Error) lub jakaś opisana w kontakcie interakcja nie została wywołana lub była z nim niezgodna.
Napiszmy teraz kod przykładowego klienta wykorzystywanego w teście. Będzie to standardowy przypadek użycia reaktywnego webClienta do odczytu danych z endpointu /foo:
public Flux<Foo> invokeProvider() { return webClient .get() .uri("/foo") .accept(MediaType.APPLICATION_JSON) .retrieve() .bodyToFlux(Foo.class); }
Jesteśmy teraz gotowi do wykonania testu. Uruchomi on dostarczany przez framework serwer HTTP – wywoła go, używając należącej do klienta klasy adaptera, a następnie zweryfikuje odpowiedzi. Dodatkowo Pact utworzy w katalogu build/pacts plik JSON będący reprezentacją paktu. Plik ten należy następnie udostępnić testom kontraktu providera serwisu.
Test dla dostawcy
Ponieważ mamy już gotowy plik kontraktu, test dla providera serwisu będzie nieco bardziej zwięzły. Tym razem użyjemy frameworku JUnit, ponieważ Spockowy wzorzec given – when – then nie byłby tutaj zbyt pomocny:
@RunWith(RestPactRunner.class) @Provider("providerService") @PactFolder("pacts") public class ProviderRouterPactTest { @TestTarget public WebFluxTarget target = new WebFluxTarget(); private ProviderHandler handler = new ProviderHandler(); private RouterFunction<ServerResponse> routerFunction = new ProviderRouter(handler).routes(); @Before public void setup() { target.setRouterFunction(routerFunction); } }
Jak możemy zauważyć, kod nie zawiera żadnych metod testowych. Dzieje się tak dlatego, że testowane wywołania i asercje są już obecne w pliku kontraktu. RestPactRunner (wskazany w adnotacji @RunWith) użyje tego pliku i zajmie się wykonaniem przypadków testowych w nim opisanych oraz weryfikacją odpowiedzi. Musimy jeszcze poinformować runnera o lokalizacji kontraktów (w tym przypadku poprzez adnotację @PactFolder wskazujemy katalog na dysku) i nazwie dostawcy (za pomocą adnotacji @Provider). Nazwa ta jest o tyle istotna, że silnik Pactu będzie wybierać kontrakty do przetestowania, porównując ją z nazwą podaną w teście klienta jako .hasPactWith(„providerService”). Adnotacja @TestTarget jest odpowiedzialna za wskazanie celu do testowania. Dla endpointów WebFluxowych będzie to instancja WebFluxTarget. Należy w niej ustawić już bezpośrednio routerFunction, której używamy w kodzie dostawcy. Wygodnym miejscem do zrobienia tego jest tutaj metoda setup() testu.
Należy jeszcze wspomnieć o klasach ProviderHandler oraz ProviderRouter. Są to elementy implementacji przykładowego dostawcy. Router jest odpowiedzialny za budowanie instancji RouterFunction, które wiążą ścieżki URL z kodem handlera:
@Configuration @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) class ProviderRouter { ProviderHandler handler; @Bean RouterFunction<ServerResponse> routes() { return route() .GET("/foo", accept(APPLICATION_JSON), handler::getFoo) .build(); } }
Natomiast metoda handler::getFoo z powyższego przykładu jest odpowiedzialna za zwracanie Mono zawierającego odpowiedź dostawcy:
Mono<ServerResponse> getFoo(ServerRequest request) { return ServerResponse .ok() .contentType(APPLICATION_JSON) .body(Flux.just( new Foo(1l, "Foo"), new Foo(2l, "Bar") ), Foo.class); }
Handler zwraca tutaj dwa obiekty Foo tak jak jest to określone w kontakcie między dostawcą a klientem.
Uruchomienie testu dla dostawcy będzie teraz skutkować wczytaniem pliku kontraktu, dopasowaniem odpowiedniego dla ścieżki HTTP handlera, wywołaniem go i weryfikacją, czy odpowiedź systemu jest zgodna z tą określoną w kontrakcie.
Podsumowanie
Pact Consumer Driven Contracts to bardzo przydatne narzędzie do zapewniania spójności pomiędzy elementami złożonego systemu. Z pewnością jego zaletą jest szeroki wachlarz wspieranych technologii, który pozwala na testowanie oparte na kontraktach w heterogenicznym środowisku. Teraz do tego spektrum dołączył kolejny element – technologia Spring WebFlux, dzięki czemu zyskaliśmy możliwość wykonywania testów względem reaktywnych dostawców.
Kod źródłowy przykładów użytych w tym artykule jest dostępny w repozytorium Githuba.
Źródła
- Strona Github Pact JVM
- Dokumentacja Spring WebFlux
- „Consumer-Driven Contracts: A Service Evolution Pattern”, Ian Robinson
- Przykład integracji Pact – WebFlux na Githubie