Po co testerowi Docker
Najbardziej frustrujący moment w mojej pracy z testami integracyjnymi to nie jest pisanie asercji. To jest czekanie. Czekanie, aż ktoś odblokuje wspólną bazę danych, aż skończy się deploy na współdzielone środowisko, aż kolega przestanie testować migrację na tej samej instancji, na której ja chcę uruchomić swój zestaw. Wspólne środowisko testowe ma jedną zaletę - jest jedno, więc łatwo je opisać. I ma jedną wadę, która kasuje tę zaletę: jest jedno, więc wszyscy się o nie biją.
Kiedy pisałem Przygotowanie środowiska, instalowałem zależności wprost na swojej maszynie. To działa dokładnie do momentu, w którym potrzebuję dwóch wersji tej samej bazy albo chcę wrócić do czystego stanu bez odinstalowywania połowy systemu.
Dlatego coraz częściej idę w drugą stronę: zależność, której potrzebuję do testu, podnoszę na chwilę u siebie, a po przebiegu ją zabijam. To jest cały pomysł na ten wpis. Nie orkiestracja, nie klaster, nie zarządzanie kontenerem z poziomu kodu testu. Jedna komenda, jedna zależność, jedno sprzątanie.
Co mam zainstalowane
Zanim cokolwiek uruchomię, sprawdzam, z czym pracuję:
$ docker version --format '{{.Server.Version}}'
20.10.2Linia 20.10 jest świeża. Engine 20.10.0 wyszedł 9 grudnia 2020, a 20.10.2 ma notatki z 4 stycznia 2021. Na maszynach z Windows i macOS nie instaluję Engine osobno, tylko biorę Docker Desktop - wersja 3.1.0 pojawiła się dosłownie wczoraj, 14 stycznia. Warto pamiętać, że numer Desktopa i numer Engine w jego maszynie wirtualnej to dwie różne rzeczy. Nie zgaduję, tylko czytam to, co pokazuje docker version u mnie, i tę wersję podaję zespołowi, kiedy ktoś nie może odtworzyć mojego przebiegu.
Jeżeli powyższa komenda nic nie zwraca, to znaczy, że demon nie działa i dalsza część wpisu nie ma sensu. Na Linuksie sprawdzam usługę, na Desktopie po prostu patrzę, czy ikonka wieloryba jest zielona.
Minimalny docker run
Najczęściej potrzebuję bazy danych. Biorę oficjalny obraz i przypinam konkretny tag:
$ docker run -d \
--name qa-postgres \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=shop \
-p 55432:5432 \
postgres:13.1Kilka rzeczy, które w tej komendzie są celowe.
postgres:13.1, a nie postgres:latest. latest to nie jest wersja, to jest ruchomy wskaźnik. Jeżeli test przechodzi u mnie w styczniu, a u kogoś w marcu nie przechodzi, chcę mieć pewność, że oboje mieliśmy ten sam silnik. Pinowanie tagu jest najtańszą rzeczą, jaką mogę zrobić dla powtarzalności.
-p 55432:5432, a nie -p 5432:5432. Po lewej stronie jest port na mojej maszynie, po prawej port w kontenerze. Jeżeli mam lokalnie zainstalowanego Postgresa, to standardowy port jest zajęty i kontener się nie podniesie. Świadomie wybieram wysoki, nietypowy port, żeby nie zgadywać później, do czego łączy się mój test. Connection string w konfiguracji testów wskazuje wtedy na localhost:55432.
--name qa-postgres. Bez nazwy Docker wymyśli losową i przy każdej kolejnej komendzie będę kopiował identyfikator. Z nazwą mam stały uchwyt do logów, exec i usuwania.
-d odpina kontener od terminala. Kiedy debuguję start, uruchamiam bez -d i patrzę na wyjście na żywo.
Jeżeli baza jest mi potrzebna tylko na jeden przebieg i nie zależy mi na danych, dokładam --rm, żeby kontener zniknął po zatrzymaniu, oraz trzymam katalog danych w pamięci:
$ docker run -d --rm \
--name qa-postgres \
-e POSTGRES_PASSWORD=test \
-p 55432:5432 \
--tmpfs /var/lib/postgresql/data \
postgres:13.1Taki kontener jest szybszy przy zapisie i z definicji czysty przy każdym starcie. Nie używam tego wariantu, kiedy chcę obejrzeć dane po nieudanym teście.
Porty, logi, gotowość
Najczęstszy błąd, jaki popełniałem na początku, to uruchomienie testu zaraz po docker run. Kontener działa, ale proces w środku jeszcze się inicjalizuje i test dostaje odmowę połączenia. docker run kończy się, gdy kontener wystartował, a nie gdy aplikacja w nim jest gotowa.
Pierwsze, co sprawdzam, to czy kontener w ogóle żyje i jakie porty wystawia:
$ docker ps --filter name=qa-postgres
CONTAINER ID IMAGE STATUS PORTS NAMES
2f1c9a7d5e10 postgres:13.1 Up 8 seconds 0.0.0.0:55432->5432/tcp qa-postgresJeżeli kontenera tu nie ma, dodaję -a i sprawdzam kod wyjścia, a potem czytam logi:
$ docker logs qa-postgresLogi rozwiązują większość problemów: zły wolumen, brak wymaganej zmiennej środowiskowej, konflikt portu. Warto się do nich przyzwyczaić, zanim zacznie się obwiniać test.
Gotowość sprawdzam narzędziem, które jest w obrazie:
$ docker exec qa-postgres pg_isready -U postgres
/var/run/postgresql:5432 - accepting connectionsTo jest dokładnie ten fragment, który opłaca się zapisać w skrypcie, bo powtarzam go codziennie.
Krótki skrypt startowy
Nie buduję wokół tego frameworka. Wystarczy jeden plik, który wkładam do repozytorium z testami, obok pliku konfiguracyjnego:
#!/usr/bin/env bash
set -euo pipefail
NAME=qa-postgres
IMAGE=postgres:13.1
PORT=55432
# jesli poprzedni przebieg cos zostawil, zaczynamy od czystego stanu
docker rm -f "$NAME" > /dev/null 2>&1 || true
docker run -d \
--name "$NAME" \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=shop \
-p "${PORT}:5432" \
"$IMAGE" > /dev/null
for _ in $(seq 1 30); do
if docker exec "$NAME" pg_isready -U postgres -d shop > /dev/null 2>&1; then
echo "Baza gotowa na localhost:${PORT}"
exit 0
fi
sleep 1
done
echo "Baza nie wstala w 30 sekund, logi ponizej:"
docker logs "$NAME"
exit 1Skrypt robi trzy rzeczy: kasuje poprzedni kontener o tej nazwie, podnosi nowy i czeka na gotowość zamiast na sleep 10 w ciemno. Kod wyjścia jest uczciwy, więc mogę go wywołać przed testami i przerwać przebieg, jeżeli zależność nie wstała. Tester, który dostaje takie repozytorium, nie musi wiedzieć nic o Dockerze poza tym, że ma go zainstalowanego.
Sprzątanie
Ta część jest ważniejsza, niż wygląda. Porzucone kontenery to najczęstsza przyczyna sytuacji “u mnie działa” w wariancie odwrotnym: u mnie nie działa, bo trzymam z wczoraj kontener na tym samym porcie albo z danymi po nieudanej migracji.
$ docker stop qa-postgres
$ docker rm qa-postgresAlbo jednym ruchem, kiedy kontener jest mi już obojętny:
$ docker rm -f qa-postgresRaz na jakiś czas robię przegląd tego, co zostało:
$ docker ps -a
$ docker volume lsKontener usunięty poleceniem docker rm nie kasuje automatycznie wolumenu z danymi, który obraz utworzył. Jeżeli po tygodniu widzę listę anonimowych wolumenów, to znaczy, że każdy mój przebieg zostawiał ślad. docker rm -v usuwa wolumeny powiązane z kontenerem, a przegląd zbiorczy robię świadomie i po sprawdzeniu listy, bo prune nie pyta drugi raz.
Moja zasada jest prosta: kontener testowy ma żyć tyle, co przebieg testów. Jeżeli muszę go trzymać dłużej, to znaczy, że przestał być środowiskiem na żądanie, a stał się kolejnym małym serwerem, którym trzeba się opiekować. To jest dokładnie ten problem, od którego uciekałem.
Czego ten wpis nie pokrywa
Dwie rzeczy zostawiam świadomie na później.
Pierwsza: wiele serwisów naraz. Kiedy potrzebuję bazy, brokera i mocka jednocześnie, ręczne docker run przestaje być wygodne i przenoszę to do pliku deklaratywnego uruchamianego przez docker-compose (u siebie mam przypiętą wersję 1.27.4). To osobny temat, z własnymi pułapkami wokół sieci i kolejności startu.
Druga: zarządzanie cyklem życia kontenera z poziomu kodu testu, żeby to nie skrypt, tylko sam test decydował, co i kiedy podnieść. To jest ciekawy kierunek i chcę go sprawdzić, ale nie mam jeszcze wyników, na których mógłbym oprzeć wpis.
Zanim jednak zacznę cokolwiek automatyzować głębiej, warto się cofnąć o krok i spojrzeć na całość, czyli na to, jak powinien wyglądać proces testowy. Docker sam z siebie nie poprawia jakości. Skraca tylko pętlę zwrotną, a to i tak jest bardzo dużo.

