Po co mock HTTP w kontenerze
W lutym, kiedy pisałem o tym, jak składam środowisko testowe w Compose, zostawiłem sobie jedną rzecz na później: mock HTTP zamiast prawdziwej usługi zewnętrznej. Wracam do tego dzisiaj, bo problem sam się przypomniał.
Aplikacja, którą testuję, ma bazę i cache u siebie, ale katalog produktów pobiera z API partnera. Wszystko, co robię z tym API, jest niewygodne. Sandbox partnera bywa wyłączony akurat wtedy, kiedy uruchamiam regresję. Ma limit zapytań, więc równoległy przebieg na CI potrafi dostać 429 i test wygląda na zepsuty, choć zepsuty jest limit. Dane w sandboxie zmieniają się bez uprzedzenia, więc asercja na cenie żyje dwa tygodnie. A scenariuszy, na których naprawdę mi zależy, czyli timeout, 503 i pusta odpowiedź, nie umiem tam wywołać na żądanie. Nie mam jak poprosić partnera, żeby na minutę popsuł swoje środowisko.
Zależności ciężkie odcinam kontenerem od stycznia, kiedy opisywałem Docker dla QA. W kwietniu poszedłem o krok dalej i podnosiłem prawdziwą bazę MSSQL z poziomu testu. Tu jest inaczej, i to jest sedno tego wpisu: nie chcę uruchamiać prawdziwego partnera w kontenerze, bo nie mam jego kodu ani nie chcę go mieć. Chcę postawić coś, co odpowiada po HTTP dokładnie tak, jak umówiliśmy się w kontrakcie, i co potrafi na moje żądanie odpowiedzieć błędem. Do tego używam WireMocka, czyli serwera HTTP, któremu opisuję w JSON-ie pary “jak przyjdzie takie zapytanie, odpowiedz tak”.
Dwie ścieżki maja 2021: JAR standalone vs obraz rodolpheche/wiremock
WireMocka można podnieść na dwa sposoby i oba trzymam u siebie, bo służą do czego innego.
Pierwsza to standalone JAR uruchamiany przez java -jar. Startuje w ułamku sekundy, nie wymaga Dockera i jest najwygodniejszy, kiedy siedzę nad jednym scenariuszem i co chwilę zmieniam odpowiedź. Wymaga tylko Javy na maszynie. Druga to obraz kontenera. Wybieram ją wtedy, gdy mock ma być częścią środowiska, a nie moją prywatną zabawką: kiedy ląduje w docker-compose.yml obok bazy i aplikacji, i kiedy ten sam plik ma zadziałać na agencie CI, gdzie Javy może w ogóle nie być.
Tu jest jedna rzecz, którą warto powiedzieć wprost, bo sam się na nią nadziałem. Nie ma dziś obrazu publikowanego przez organizację WireMocka. Obraz, którego używam, to rodolpheche/wiremock i jest to obraz społecznościowy, utrzymywany poza projektem. Konsekwencja jest praktyczna: numer wersji JAR-a i numer taga obrazu nie muszą iść w tym samym tempie.
WireMock 2.28.0 (11 maja) - artefakt com.github.tomakehurst:wiremock-jre8-standalone:2.28.0
Najświeższa wersja to 2.28.0, opublikowana na Maven Central 11 maja 2021, czyli cztery dni temu. Poprzednia linia, 2.27.2, jest z 10 września 2020 i nadal jest w obiegu.
Artefakt, który mnie interesuje, to wariant standalone com.github.tomakehurst:wiremock-jre8-standalone:2.28.0. Nazwa jre8 bywa myląca, więc tłumaczę ją zespołowi za każdym razem. To nie jest wymóg “dokładnie Java 8”. To wariant zbudowany dla Javy 8 i nowszej, w odróżnieniu od starszego artefaktu dla Javy 7. standalone oznacza, że JAR ma spakowane w sobie wszystkie zależności razem z serwerem HTTP, więc nie potrzebuję żadnego projektu wokół niego.
Pobieram go raz i trzymam w katalogu z testami:
$ wget -q -O wiremock.jar \
https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.28.0/wiremock-jre8-standalone-2.28.0.jar
$ java -jar wiremock.jar --port 8090 --root-dir ./wiremock --verboseTrzy flagi, których używam zawsze. --port bo domyślne 8080 mam zwykle zajęte przez aplikację pod testem. --root-dir bo wskazuje katalog, w którym WireMock szuka podkatalogów mappings/ i __files/, a ja chcę je trzymać w repozytorium z testami, a nie tam, skąd akurat odpaliłem terminal. --verbose bo bez tego, kiedy zapytanie nie pasuje do żadnego stubu, patrzę w pustą konsolę i zgaduję. Z --verbose dostaję w logu pełne zapytanie i listę najbliższych dopasowań z informacją, co się nie zgodziło.
docker run community image + volume na mappings/ i __files/
Teraz to samo w kontenerze. Struktura katalogów jest identyczna, bo to ta sama aplikacja w środku:
wiremock/
mappings/
catalog-product-1001.json
catalog-product-unavailable.json
__files/
catalog-page-1.jsonI uruchomienie:
$ docker run -d \
--name catalog-mock \
-p 58090:8080 \
-v "$(pwd)/wiremock/mappings:/home/wiremock/mappings" \
-v "$(pwd)/wiremock/__files:/home/wiremock/__files" \
rodolpheche/wiremock:2.27.2 \
--verboseKilka decyzji jest tu celowych.
Tag jest przypięty, i to na 2.27.2, a nie na latest. Zasada jest ta sama, co przy Postgresie w styczniu, ale tutaj dochodzi drugi powód. Na dzień pisania tego wpisu nie ma na Hubie taga 2.28.0 dla tego obrazu, mimo że JAR leży na Maven Central od czterech dni, więc biorę linię 2.27.2 z zeszłego roku. Obraz jest społecznościowy i nadganiania nowych wersji nikt mi nie gwarantuje. Wolę mieć to jawnie zapisane w komendzie niż odkryć za pół roku, że latest przyjechał w środku sprintu.
Wolumeny montuję pod /home/wiremock, bo to jest katalog roboczy w tym obrazie. Dzięki temu pliki stubów zostają w repozytorium z testami, a kontener jest w pełni jednorazowy. Kasuję go i podnoszę od nowa bez żadnej straty.
Port po lewej stronie jest wysoki i nietypowy, tak jak w poprzednich wpisach, bo w kontenerze WireMock i tak słucha na 8080. Argumenty dopisane po nazwie obrazu trafiają wprost do WireMocka, więc --verbose działa tu tak samo jak przy java -jar.
Jeżeli koniecznie potrzebuję w kontenerze wersji 2.28.0, to nie szukam taga, tylko piszę kilka linijek Dockerfile i biorę JAR wprost z Maven Central:
FROM openjdk:8-jre
ARG WIREMOCK_VERSION=2.28.0
WORKDIR /home/wiremock
RUN wget -q -O /wiremock.jar \
https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/${WIREMOCK_VERSION}/wiremock-jre8-standalone-${WIREMOCK_VERSION}.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/wiremock.jar", "--port", "8080", "--root-dir", "/home/wiremock"]To jest kilkanaście sekund pracy i zdejmuje zależność od cudzego harmonogramu publikowania. Na razie jednak w Compose zostaję przy przypiętym obrazie społecznościowym, bo nie chcę, żeby każdy klon repozytorium zaczynał się od budowania obrazu.
Minimalny stub JSON i sprawdzenie GET /__admin/
Stub to jeden plik JSON w mappings/. Struktura jest zawsze taka sama: request opisuje, co ma pasować, response opisuje, co odesłać.
wiremock/mappings/catalog-product-1001.json:
{
"request": {
"method": "GET",
"urlPath": "/api/products/1001"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"id": 1001,
"name": "Kubek termiczny 400 ml",
"price": 59.9,
"currency": "PLN",
"inStock": true
}
}
}Zwracam uwagę na urlPath, a nie url. url porównuje całość razem z query stringiem, więc /api/products/1001?lang=pl już nie trafi. urlPath porównuje samą ścieżkę i to jest zwykle to, czego chcę.
Drugi plik, wiremock/mappings/catalog-product-unavailable.json, to ścieżka błędu, czyli powód, dla którego w ogóle to robię:
{
"request": {
"method": "GET",
"urlPath": "/api/products/9999"
},
"response": {
"status": 503,
"fixedDelayMilliseconds": 3000
}
}Trzy sekundy opóźnienia i 503. Na sandboxie partnera nie mam jak tego wywołać, a tutaj to jest dziesięć linijek. Do tego dochodzi test, który wreszcie ma sens: sprawdzam, czy moja aplikacja odpowiada rozsądnym komunikatem, a nie białą stroną. Kiedy odpowiedź jest większa, nie wklejam jej do mappingu, tylko wyciągam do __files/ i podaję "bodyFileName": "catalog-page-1.json", dzięki czemu plik z payloadem daje się otworzyć i sformatować jak zwykły JSON.
Sprawdzenie, czy to w ogóle wstało, robię przez API administracyjne. Ono jest zawsze pod /__admin/:
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:58090/__admin/
200
$ curl -s http://localhost:58090/__admin/mappings
$ curl -s http://localhost:58090/api/products/1001
{"id":1001,"name":"Kubek termiczny 400 ml","price":59.9,"currency":"PLN","inStock":true}Pierwsza komenda to mój test gotowości. Odpowiada dopiero wtedy, kiedy serwer naprawdę przyjmuje ruch, więc nadaje się do pętli oczekiwania. Druga mówi mi, czy WireMock w ogóle zobaczył moje pliki, a jeżeli lista jest pusta, to w dziewięciu przypadkach na dziesięć pomyliłem ścieżkę wolumenu i kontener zagląda do swojego własnego pustego katalogu. Trzecia odpytuje już sam stub.
Zostają jeszcze dwie końcówki administracyjne, które ratują mi debugowanie. GET /__admin/requests pokazuje dziennik zapytań, które faktycznie przyszły, więc od razu widzę, czy aplikacja w ogóle zapukała i pod jaki adres. POST /__admin/mappings/reset przeładowuje stuby z dysku, więc po edycji pliku nie muszę restartować kontenera.
Compose: aplikacja pod test + WireMock w jednej sieci
Docelowo mock nie jest osobnym bytem, tylko kolejnym serwisem obok bazy i cache. U siebie mam teraz docker-compose w wersji 1.29.2 z 10 maja i Engine z linii 20.10:
$ docker-compose version --short
1.29.2Plik jest rozwinięciem tego z lutego:
version: "3.8"
services:
db:
image: postgres:13.2
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: shop
ports:
- "55432:5432"
cache:
image: redis:6.0.10
ports:
- "56379:6379"
catalog-mock:
image: rodolpheche/wiremock:2.27.2
command: ["--verbose"]
volumes:
- ./wiremock/mappings:/home/wiremock/mappings
- ./wiremock/__files:/home/wiremock/__files
ports:
- "58090:8080"
api:
image: rejestr.example/shop-api:2021.02
environment:
DATABASE_URL: postgres://postgres:test@db:5432/shop
REDIS_URL: redis://cache:6379
CATALOG_API_URL: http://catalog-mock:8080
ports:
- "58080:8080"
depends_on:
- db
- cache
- catalog-mockNajważniejsza linijka to CATALOG_API_URL: http://catalog-mock:8080. Compose tworzy dla projektu sieć, w której serwisy widzą się po nazwach, więc aplikacja łączy się do catalog-mock, a nie do localhost. Port też jest wewnętrzny, czyli 8080, a nie 58090. To jest błąd, który popełniłem przy pierwszym podejściu: wpisałem w konfiguracji aplikacji adres, którego używałem z curl na swojej maszynie, i dostałem odmowę połączenia, bo dla kontenera localhost to on sam.
Sekcję ports przy mocku i tak zostawiam, choć aplikacja jej nie potrzebuje, bo chcę mieć dostęp do /__admin/requests ze swojego terminala, kiedy test zachowuje się dziwnie.
Jedno założenie jest w tym wszystkim ukryte: aplikacja pod testem musi mieć adres partnera w konfiguracji, a nie na sztywno w kodzie. Jeżeli nie ma, to podmiana na mock jest niemożliwa i pierwszą zmianą nie jest test, tylko wyciągnięcie tego adresu do zmiennej środowiskowej. To jest zwykle jedna linijka i największa wartość, jaką ten wpis może przynieść komuś na starcie.
Gotowości pilnuję tak samo jak w lutym, tylko pytam o /__admin/:
#!/usr/bin/env bash
set -euo pipefail
docker-compose up -d
for _ in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:58090/__admin/; then
echo "Mock gotowy na localhost:58090"
exit 0
fi
sleep 1
done
echo "Mock nie wstal, logi ponizej:"
docker-compose logs --no-color catalog-mock
exit 1Po przebiegu sprzątam bez litości, czyli docker-compose down -v. Stuby są w repozytorium, więc kontener nie trzyma niczego, czego bym żałował.
Czego nie robię: Testcontainers lifecycle, oficjalny image org, Pact
Trzy rzeczy zostawiam świadomie poza tym wpisem.
Nie podnoszę WireMocka z poziomu kodu testu. W kwietniu pokazywałem, że kontenerem da się sterować z testu, i tam miało to sens, bo baza MSSQL musiała być czysta dla konkretnej klasy testowej. Mock HTTP ma inny charakter: jest częścią środowiska, stoi tak długo, jak stoi aplikacja, i chcę go widzieć w tym samym pliku, co resztę. Nie wykluczam, że wrócę do wariantu sterowanego z testu, ale najpierw chcę mieć przerobiony ten prostszy.
Nie używam obrazu publikowanego przez organizację WireMocka, bo dziś takiego nie ma. Korzystam z obrazu społecznościowego z przypiętym tagiem albo buduję własny na openjdk:8-jre. Jeżeli projekt kiedyś zacznie publikować obrazy sam, przepięcie będzie zmianą jednej linii w Compose i wtedy to zrobię.
Nie robię tu contract testingu w stylu Pacta. To jest inne narzędzie do innego problemu: Pact pilnuje, żeby kontrakt między konsumentem a dostawcą naprawdę się zgadzał i żeby dostawca dowiedział się, kiedy go złamie. WireMock takiej gwarancji nie daje. Mój stub to jest moje wyobrażenie o tym, jak partner odpowiada, i jeżeli partner zmieni pole price na grossPrice, to moje testy będą zielone, a produkcja nie. Trzymam więc dwie zasady. Stuby żyją w repozytorium i podlegają review jak kod. Poza zestawem mockowanym zostaje wąski zestaw testów, które uderzają w prawdziwy sandbox i sprawdzają wyłącznie kształt odpowiedzi.
Mock nie zastępuje integracji. Robi tylko tyle, że przestaję debugować cudze środowisko zamiast swojego kodu, i że wreszcie umiem napisać test na 503. Warto tylko co jakiś czas sprawdzić, gdzie to się mieści w całym procesie testowym, bo szybka pętla zwrotna jest środkiem, a nie celem.

