Poradnik

Testy integracyjne z wykorzystaniem WireMock w Spring Boot

java laptop

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.

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.
spring initializr

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.

Senior Developer w Tpay

Od nastoletnich lat informatyka była dla niego pasją. Uwielbia majsterkować nie tylko z językami programowania i technologiami. Z pracy stara się czerpać jak najwięcej radości i satysfakcji.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/testy-integracyjne-z-wykorzystaniem-wiremock/" order_type="social" width="100%" count_of_comments="8" ]