Wszystkie wpisy

Docker dla QA - środowiska testowe on-demand
Docker dla QA - środowiska testowe on-demand

English

Docker dla QA - środowiska testowe on-demand

Jak w styczniu 2021 stawiam na żądanie izolowane zależności testowe Dockerem - Engine 20.10, Desktop 3.1, docker run, porty, logi i sprzątanie kontenerów.

QA

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.2

Linia 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.1

Kilka 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.1

Taki 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-postgres

Jeżeli kontenera tu nie ma, dodaję -a i sprawdzam kod wyjścia, a potem czytam logi:

$ docker logs qa-postgres

Logi 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 connections

To 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 1

Skrypt 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-postgres

Albo jednym ruchem, kiedy kontener jest mi już obojętny:

$ docker rm -f qa-postgres

Raz na jakiś czas robię przegląd tego, co zostało:

$ docker ps -a
$ docker volume ls

Kontener 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.