Wsparcie dla Spring WebFlux we frameworku Pact JVM

Paweł Pelczar | Rozwój oprogramowania | 21.10.2020

Pact JVM posiada wsparcie dla Springa od dość dawna, ale po stronie dostawcy usługi było ono niestety ograniczone tylko do mockowanego Spring MVC. Począwszy od wersji 4.7.1, Pact wspiera również endpointy Spring WebFlux. W tym artykule zademonstruję użycie Pact Consumer Driven Contracts do testowania serwisów wykonanych za pomocą Spring WebFlux oraz konsumentów tych usług. Zacznę od krótkiego wprowadzenia do Consumer Driven Contract Testing i Spring WebFlux. Następnie wyjaśnię, jak połączyć te dwie technologie, aby utworzyć kontrakt i zweryfikować za jego pomocą zarówno konsumenta (consumer), jak i dostawcę (provider) usługi.

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