Testy integracyjne z wykorzystaniem WireMock w Spring Boot
Jeżeli kiedykolwiek Twój system integrował się z zewnętrznymi usługami firm trzecich, to doskonale wiesz, że droga ta może być kręta i wyboista. Realizujesz serwisy w Javie z wykorzystaniem Spring Boot? Sprawdź jak może Ci pomóc WireMock.
Źle zrealizowane kontrakty, niewłaściwie działająca autoryzacja czy uwierzytelnianie, błędnie chwycone wyjątki, timeout, internal server error – to tylko niektóre pułapki, które często są wyłapywane dopiero w środowisku produkcyjnym.
Aby temu zapobiec i ułatwić sobie pracę, warto przyjrzeć się WireMock: narzędziu, dzięki któremu w prosty sposób można zamockować zewnętrzne API w testach integracyjnych i nie tylko.
Spis treści
Use Case
Wyobraź sobie sytuację, że realizujesz nowy mikroserwis obsługi płatności w sklepie internetowym, w którym będziesz komunikować się z bankiem w celu blokady środków na koncie płatnika. Wszystkie dokumentacje z bankiem zostały zatwierdzone, kontrakty ustalone, umowy podpisane, jest zielone światło na realizację systemu.
Musi być on jednak bardzo dobrze przetestowany. Z testami jednostkowymi nie powinno być problemów, ale jak dobrze przetestować komunikację z systemem bankowym? Pokażę Ci, jak łatwo napisać takie testy z WireMock.
Inicjalizacja projektu
W celu zainicjalizowania projektu wchodzisz na stronę https://start.spring.io/, na której w prosty sposób możesz wygenerować projekt ze wszystkim zależnościami.
Na start potrzebujesz:
- Spring Web do realizacji REST API,
- Spring Reactive Web do komunikacji z zewnętrznym serwisem,
- Contract Stub Runner, aby w łatwy sposób korzystać z WireMock.
Stwórz pierwszy kod:
Klasa reprezentująca żądanie blokady środków na podstawie karty płatniczej w banku:
public record BankChargeRequest(Integer amount, String cardNumber, String cvc){ }
Serwis do komunikacji z bankiem:
@Service public class BankPaymentService { @Value("${bank.url}") private String bankUrl; public BankChargeResponse sendPaymentToBank(BankChargeRequest dto){ WebClient webClient = WebClient.builder().baseUrl(bankUrl).build(); return webClient .post() .uri("/charge") .body(BodyInserters.fromValue(dto)) .retrieve() .bodyToMono(BankChargeResponse.class) .block(); } }
Klasa reprezentująca odpowiedź z banku na żądanie blokady środków:
public record BankChargeResponse(String id, PaymentStatus status, String message) { }
Słownik “statusy płatności”:
public enum PaymentStatus { ACCEPTED, FAILED }
Serwis do realizacji płatności oraz walidowania odpowiedzi z banku:
@Service public class PaymentService { private final BankPaymentService bankPaymentService; public PaymentService(BankPaymentService bankPaymentService) { this.bankPaymentService = bankPaymentService; } public BankChargeResponse createPayment(BankChargeRequest request) throws PaymentStatusInvalidException, PaymentFailedException { BankChargeResponse response = bankPaymentService.sendPaymentToBank(request); validResponse(response); return response; } private void validResponse(BankChargeResponse response) throws PaymentFailedException, PaymentStatusInvalidException { if (response == null) { throw new PaymentFailedException(); } if (response.status() == null) { throw new PaymentStatusInvalidException(); } if (PaymentStatus.FAILED.equals(response.status())) { throw new PaymentFailedException(); } } }
API przyjmujące dane ze sklepu internetowego:
@RestController @RequestMapping("/payments") public class PaymentsController { private PaymentService paymentService; public PaymentsController(PaymentService createPayment) { this.paymentService = createPayment; } @PostMapping public PaymentCreateResponse create(BankChargeRequest dto) throws PaymentStatusInvalidException, PaymentFailedException { BankChargeResponse bankChargeResponse = paymentService.createPayment(dto); return new PaymentCreateResponse(bankChargeResponse.id()); } }
Po wykonaniu tych kroków efektem jest prosty kod, który odbiera informacje o potrzebie zrealizowania płatności ze sklepu internetowego, komunikuje się z bankiem i waliduje jego odpowiedź.
Pierwsze testy integracyjne
Zacznij od napisania poprawnej odpowiedzi w formacie JSON, którą powinien zwrócić nam bank, gdy blokada środków się powiodła:
{ "id" : "1", "status": "ACCEPTED" }
Napisz test integracyjny, w którym przygotujesz mock z pozytywną odpowiedzią banku.
W celu zrealizowania testu integracyjnego i uruchomienia go za pomocą kontenera Spring Boot należy wykorzystać adnotację @SpringBootTest.
Kolejno podaj adnotację, dzięki której zostanie uruchomiony WireMock: @WireMockTest(httpPort = 8081). httpPort określa, na jakim porcie ma zostać uruchomiony WireMock. Jeżeli nie podasz tej wartości, port będzie wylosowany.
@SpringBootTest @WireMockTest(httpPort = 8081) class BankPaymentServiceTest { }
Następnie wstrzykujesz swój serwis do komunikacji z bankiem, a także w łatwy sposób poprzez adnotację @DynamicPropertySource, podmieniasz adres URL do banku. W tym wypadku wskazujesz mu adres http://localhost:8081, na którym uruchomiony jest WireMock.
public BankPaymentServiceTest(BankPaymentService bankPaymentService) { this.bankPaymentService = bankPaymentService; } @DynamicPropertySource static void configure(DynamicPropertyRegistry registry) { registry.add("bank.url", () -> "http://localhost:8081"); }
Zaczytaj poprawną odpowiedź. W tym celu możesz wykorzystać klasę i metodę dostarczoną przez WireMock, ułatwiającą odczytywanie odpowiedzi z pliku (IOUtils.resourceToString).
Trzymanie wartości odpowiedzi w plikach poprawia czytelność testu. Jeżeli umieścisz tę wartość jako zwykły tekst, test może mocno się rozrosnąć i stać się uciążliwy w utrzymaniu oraz mało czytelny.
@Test void should_return_correct_payment() throws IOException { // given String responseBody = IOUtils.resourceToString("/files/bank-payment-service-test/correct-response.json", StandardCharsets.UTF_8); } }
Kolejno konfiguruj HTTP mock z wykorzystaniem WireMock:
stubFor(post(urlEqualTo("/charge")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(responseBody) ) );
W powyższy sposób zaadresowałaś/eś do WireMock utworzenie metody POST na adresie http://localhost:8081/charge. W odpowiedzi na request zostanie zwrócony status 200 z nagłówkiem „Content-Type”, „application/json” oraz body, które napisaliśmy parę linijek wyżej.
Sprawdź, czy to faktycznie działa: wywołaj serwis oraz dopisz sprawdzenie wartości:
BankChargeResponse response = bankPaymentService.sendPaymentToBank( new BankChargeRequest(100, "4790627202424467", "141") ); // then assertAll(() -> { assertEquals("1", response.id()); assertEquals(PaymentStatus.ACCEPTED, response.status()); });
Klasa z pierwszym testem powinna wyglądać tak:
@SpringBootTest @WireMockTest(httpPort = 8081) class BankPaymentServiceTest { private final BankPaymentService bankPaymentService; public BankPaymentServiceTest(BankPaymentService bankPaymentService) { this.bankPaymentService = bankPaymentService; } @DynamicPropertySource static void configure(DynamicPropertyRegistry registry) { registry.add("bank.url", () -> "http://localhost:8081"); } @Test void should_return_correct_payment() throws IOException { // given String responseBody = IOUtils.resourceToString("/files/bank-payment-service-test/correct-response.json", StandardCharsets.UTF_8); stubFor(post(urlEqualTo("/charge")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(responseBody) ) ); // when BankChargeResponse response = bankPaymentService.sendPaymentToBank(new BankChargeRequest(100, "4790627202424467", "141")); // then assertAll(() -> { assertEquals("1", response.id()); assertEquals(PaymentStatus.ACCEPTED, response.status()); }); } }
Test przeszedł pozytywnie.
Zrealizuj teraz kolejny test, aby sprawdzić, czy odpowiedź HTTP 500 – internal server error, zwróci w kodzie wyjątek:
@Test void should_return_exception() throws IOException { // given stubFor(post(urlEqualTo("/charge")) .willReturn( aResponse() .withStatus(500) ) ); // when // then assertThrows(Exception.class, () -> { bankPaymentService.sendPaymentToBank(new BankChargeRequest(100, "4790627202424467", "")); }); }
W WireMock w bardzo intuicyjny sposób możemy manipulować kodami http w odpowiedziach i testować różne ścieżki negatywne.
Co jeszcze potrafi WireMock? Kilka przydatnych funkcjonalności
Tworzenie endpointów różnych typów
Endpointy (GET, POST, PUT, DELETE) możesz z pomocą WireMock tworzyć z wykorzystaniem zarówno equal, jak i regexp:
stubFor(get(urlMatching("regex"))); stubFor(delete(urlEqualTo("/test"))); stubFor(post(urlEqualTo("/test"))); stubFor(put("/test"));
Oczekiwanie odpowiedniej wartości w żądaniu HTTP.
Jeżeli nie zostaną spełnione warunki, mock zwróci status 404 – Not Found, ponieważ nie był w stanie dopasować zapytania z odpowiedzią.
stubFor(post("/test").withRequestBody("JSON"))
Parametryzowanie zwracanej wartości
WireMock posiada obszerny mechanizm parametryzowania zwracanej odpowiedzi. W tym celu musisz użyć specjalnej składni. Zapis ten musi się zaczynać i kończyć dwiema klamrami; podczas zwracania body algorytm podmieni zapis pod zadane wartości, np.:
{{request.headers.}} - Wartość nagłówka zapytania {{now}} - Dzisiejsza data {{now offset='3 days'}} - Dzisiejsza data + 3 dni {{randomValue length=33 type='ALPHANUMERIC'}} - Losowa wartość alfanumeryczna o długości 33 znaków {{randomValue length=27 type='ALPHABETIC' uppercase=true}} - Losowa wartość zawierająca tylko litery z wielkich liter o długości 27 znaków {{randomInt lower=5 upper=9}} - Przedział liczb od 0 do 4 lub większych od 9 {{math 1 '+' 2}} - dodawanie liczb {{#if (contains 'abcde' 'abc')}}YES{{/if}} - wyrażenie logiczne
Gdy chcemy, aby nasza odpowiedź zawierała wartości z body requestu, możemy wykorzystać JSONPath, np:
{{jsonPath request.body '$.amount'}} - wartość amount z request body
Podane przykłady to tylko garstka możliwości parametryzowania odpowiedzi; po więcej zapraszam na stronę oficjalnej dokumentacji: WireMock Response Templating
Opóźnienie odpowiedzi/symulowanie usterek serwisu
Dużym plusem wykorzystania WireMock w testach integracyjnych jest możliwość wygenerowania wadliwego zachowania usługi: możesz wtedy bardzo łatwo wychwycić i załatać błąd przed wdrożeniem serwisu na produkcję. Kilka przykładów poniżej.
Symulowanie opóźnienia o 60 sekund:
stubFor(post(urlEqualTo("/charge")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(responseBody) .withFixedDelay(60000) ) );
Symulowanie zerwania połączenia:
stubFor(post(urlEqualTo("/charge")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(responseBody) .withFault(Fault.CONNECTION_RESET_BY_PEER) ) );
Symulowanie pustej odpowiedzi:
stubFor(post(urlEqualTo("/charge")) .willReturn( aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody(responseBody) .withFault(Fault.EMPTY_RESPONSE) ) );
Weryfikowanie żądań
Wszystkie żądania i zapytania ze strony aplikacji Wiremock zapisuje w pamięci, dzięki czemu w łatwy sposób w teście możesz sprawdzić, czy endpoint został wywołany określoną ilość razy.
verify(exactly(1), postRequestedFor(urlEqualTo("/charge"))); verify(lessThan(5), postRequestedFor(urlEqualTo("/charge")));
To tylko część możliwości, jaką daje WireMock z Spring Boot. Aby zgłębić temat i dowiedzieć się więcej — zapraszam do oficjalnej dokumentacji WireMock Docs
Kod, który został zrealizowany w artykule, dostępny jest na Github:
Github Payments
Zdjęcie główne pochodzi z unsplash.com.