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.
Podobne artykuły
Jak wyglądały zarobki Java Developerów w 2022 roku?
Z Javą jest jak z językiem angielskim - podstawy są proste, dalej trzeba się już postarać
Java – co to? Dla kogo? I ile zarobimy?

