Dwa posty, których tu nie powtarzam
Ten wpis stoi na dwóch wcześniejszych i nie ma sensu bez nich, więc zaczynam od uczciwego odesłania.
W kwietniu zeszłego roku opisałem, jak podnoszę bazę z poziomu kodu testu. To był wpis lokalny: mój laptop, mój Docker, jeden [Fact] i połączenie do kontenera, który wstał i zaraz zniknął. W listopadzie zabrałem to samo na agenta Azure Pipelines i tam padło jedyne pytanie, które naprawdę bolało: gdzie stoi demon Dockera i czy job go widzi. Odpowiedź brzmiała “na hoście agenta”, a wniosek “nie wsadzaj tego joba do container:, bo wtedy localhost procesu testowego przestaje być hostem”.
Nie przepisuję tu ani fixture’a bazy, ani mapDockerSocket. Jednego i drugiego nie da się poprawić przez powtórzenie.
Brakowało mi w tamtym pipelinie jednej warstwy: przeglądarki. Testy integracyjne z prawdziwym MSSQL mówiły mi, że warstwa danych żyje. Nic w tym samym przebiegu nie mówiło, czy człowiek jest w stanie się przez tę aplikację przeklikać. Przeglądarkę miałem od maja, kiedy postawiłem pierwszy projekt na @playwright/test, ale tamten wpis kończył się świadomie na npx playwright test na moim laptopie i obietnicą, że YAML przyjdzie później. Później jest teraz.
Cel na dzisiaj jest wąski: jeden plik pipeline’u, w którym obie warstwy lecą z tego samego commita. Kontenery z Testcontainers dla testów integracyjnych .NET i Playwright dla przejścia przez UI. Nie dwa repozytoria, nie dwa harmonogramy, nie dwie rozmowy o tym, kto ogląda który raport.
Paczka .NET zmieniła nazwę
Zanim wejdę w YAML, jedna rzecz z NuGeta, bo inaczej pierwsza komenda się nie wykona.
Do czerwca pisałem DotNet.Testcontainers i tak jest w obu poprzednich wpisach. 21 czerwca na NuGecie pojawiła się paczka o nazwie Testcontainers w wersji 2.0.0, czyli ten sam projekt pod nazwą zgodną z resztą rodziny Testcontainers. Ja stoję na 2.1.0 z 20 lipca i to jest wersja, na której robiłem wszystkie przebiegi opisane niżej.
<PackageReference Include="Testcontainers" Version="2.1.0" />Bez zakresu i bez gwiazdki, dokładnie z tego samego powodu, dla którego przypinałem 1.5.0 w listopadzie: kiedy przebieg zrobi się czerwony, chcę wiedzieć, czy zmienił się mój kod, czy biblioteka pod spodem.
Dwie praktyczne uwagi z migracji.
Po pierwsze, nazwa paczki wyprzedziła kod. Po podmianie PackageReference nie musiałem ruszyć ani jednego using w projekcie testowym, a builder nadal nazywa się TestcontainersBuilder. To znaczy, że przejście z linii 1.x na 2.1.0 jest u mnie jednolinijkowe i nie wymaga osobnego sprintu.
Po drugie, i to jest błąd, który zrobiłem osobiście: nie da się mieć obu paczek naraz w jednym csproj. Zostawiłem stary PackageReference obok nowego, licząc na to, że NuGet jakoś to poukłada, i dostałem kompilację pełną konfliktów na typach o tych samych nazwach z dwóch assembly. Albo stara nazwa, albo nowa. Jeżeli zespół nie jest gotowy na przejście, sensownym pinem sierpnia jest DotNet.Testcontainers w 1.6.0 z 7 czerwca, ale wtedy w projekcie nie ma ani jednej linijki z nową paczką.
Czego świadomie nie robię z tego tematu: nie piszę drugiego wpisu o sprzątaniu kontenerów. Resource Reaper w tej linii po prostu działa i zamyka to, co zostało po przerwanym przebiegu. To jest szczegół implementacyjny biblioteki, a nie coś, co muszę konfigurować w YAML-u.
Playwright 1.25 na agencie
Po stronie JavaScriptu pin jest równie prosty. @playwright/test w wersji 1.25.0 wyszło 11 sierpnia, cztery dni temu, i to jest jedyna wersja, na której cokolwiek tu sprawdzałem.
{
"engines": {
"node": ">=16"
},
"devDependencies": {
"@playwright/test": "1.25.0"
}
}Node trzymam na 16 LTS i to nie jest lenistwo. 1.25 nadal formalnie wspiera Node 12, co dobrze pokazuje, jak szeroka jest matryca wspieranych wersji, i jednocześnie jak niewiele mówi o tym, na czym chcę stawiać CI. Szesnastka jest środkiem: dłużej niż 12, spokojniej niż gałąź, która LTS dostanie dopiero jesienią. Na agencie ustawiam ją jawnie, bo domyślna wersja Node’a na obrazie agenta jest ruchoma tak samo jak ubuntu-latest.
Do uruchomienia przeglądarek na czystym Linuksie mam dwie drogi i obie są legalne.
Pierwsza to instalacja bibliotek systemowych na hoście agenta:
$ npx playwright install --with-depsTo dokłada zależności systemowe i pobiera buildy Chromium, Firefoksa i WebKit zbundlowane z 1.25.0. Kosztuje minutę czy dwie zimnego startu przy każdym przebiegu.
Druga to gotowy obraz z przeglądarkami, mcr.microsoft.com/playwright:v1.25.0-focal. Wydanie 1.25 dorzuca też wariant v1.25.0-jammy, ale skoro trzymam agenta na ubuntu-20.04, zostaję przy Focalu, żeby glibc w obrazie i na hoście się nie rozjeżdżały.
I tu jest pułapka, przez którą ta sekcja w ogóle powstała. Kusi, żeby ten obraz podpiąć jako container: całego joba, skoro i tak zawiera wszystko, co potrzebne. Jeżeli w tym samym jobie miałby chodzić dotnet test z Testcontainers, wracam dokładnie do listopadowego problemu: kontenery bazy stają się rodzeństwem job containera, a nie jego dziećmi, i localhost w procesie testowym przestaje wskazywać na hosta. Obraz Playwrighta jako container job jest w porządku wtedy i tylko wtedy, gdy w tym jobie nie ma Testcontainers.
Jeden YAML, dwie warstwy
Mam więc dwie warstwy o różnych wymaganiach: jedna potrzebuje SDK .NET i socketu Dockera, druga Node’a i przeglądarek. Są dwa sposoby złożenia ich w jednym pliku i wybrałem pierwszy.
Ścieżka A, rekomendowana: dwa joby. Job pierwszy to dotnet test z Testcontainers na hoście agenta. Job drugi to Playwright. Każdy dostaje tylko to, czego potrzebuje, każdy ma własny czas i własny artefakt, a czerwony wynik od razu mówi, która warstwa padła, bez czytania logu.
Jest w tym jeden szczegół, który trzeba powiedzieć wprost, bo intuicja podpowiada coś innego. Kontener bazy podniesiony w jobie pierwszym nie dożyje joba drugiego. To są dwie różne maszyny, a nie dwa kroki na jednej. Jeżeli Playwright ma w co uderzyć, to albo job drugi sam podnosi aplikację (u mnie plikiem Compose, komendą docker compose, bo od kwietnia V2 jest GA i myślnik z komendy zniknął), albo celuje w stałe środowisko, które stoi niezależnie od pipeline’u. Przekazanie żywego URL-a z joba do joba nie jest opcją.
Ścieżka B: jeden job. Najpierw dotnet test, potem docker compose up, potem npx playwright test z BASE_URL wskazującym na localhost hosta. Ma sens przy małym projekcie, gdzie narzut na drugą maszynę, drugi checkout i drugie postawienie środowiska jest większy niż zysk z rozdzielenia. Płacę za to jednym długim jobem, w którym instaluję cały stack niezależnie od tego, czy jest potrzebny, i logiem, w którym trzeba szukać.
W obu ścieżkach obowiązuje ta sama zasada z listopada: job, który podnosi kontenery z kodu testu, stoi na hoście agenta.
Azure Pipelines i GitHub Actions
Pokażę oba, bo model jest identyczny i różnice są wyłącznie w składni. Zaczynam od Azure Pipelines, bo tam ten pipeline u mnie żyje.
trigger:
- main
pr:
- main
variables:
buildConfiguration: Release
dbImage: mcr.microsoft.com/mssql/server:2019-latest
appUrl: http://localhost:5000
jobs:
- job: integration
displayName: Testy integracyjne z Testcontainers
pool:
vmImage: ubuntu-20.04
steps:
- task: UseDotNet@2
displayName: SDK
inputs:
packageType: sdk
version: 6.0.x
- script: |
docker version
docker info --format '{{.OperatingSystem}}'
displayName: Co ma agent
- script: docker pull $(dbImage)
displayName: Pull obrazu bazy
- task: DotNetCoreCLI@2
displayName: dotnet test
inputs:
command: test
projects: "**/*IntegrationTests.csproj"
arguments: "--configuration $(buildConfiguration) --logger trx --results-directory $(Agent.TempDirectory)"
- job: e2e
displayName: Playwright 1.25
dependsOn: integration
pool:
vmImage: ubuntu-20.04
steps:
- task: NodeTool@0
displayName: Node 16
inputs:
versionSpec: 16.x
- script: npm ci
displayName: npm ci
- script: npx playwright install --with-deps
displayName: Przeglądarki i biblioteki systemowe
- script: docker compose -f docker-compose.e2e.yml up -d
displayName: Aplikacja pod testy
- script: |
for i in {1..90}; do
if curl -fsS $(appUrl)/health > /dev/null; then
echo "gotowe po ${i}s"
exit 0
fi
sleep 1
done
echo "aplikacja nie odpowiada na /health"
docker compose -f docker-compose.e2e.yml logs
exit 1
displayName: Czekam na /health
- script: npx playwright test
displayName: npx playwright test
env:
BASE_URL: $(appUrl)
CI: "true"
- task: PublishPipelineArtifact@1
condition: always()
displayName: Raport Playwrighta
inputs:
targetPath: playwright-report
artifact: playwright-reportTen sam model w GitHub Actions, na liniach akcji z tego roku:
name: tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
integration:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-dotnet@v2
with:
dotnet-version: "6.0.x"
- run: docker version
- run: dotnet test --configuration Release --logger trx --results-directory TestResults
- uses: actions/upload-artifact@v3
if: always()
with:
name: trx
path: TestResults
e2e:
runs-on: ubuntu-20.04
needs: integration
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- run: docker compose -f docker-compose.e2e.yml up -d
- name: Czekam na /health
run: |
for i in {1..90}; do
curl -fsS http://localhost:5000/health > /dev/null && exit 0
sleep 1
done
docker compose -f docker-compose.e2e.yml logs
exit 1
- run: npx playwright test
env:
BASE_URL: http://localhost:5000
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/Kilka rzeczy, które w obu plikach są celowe.
ubuntu-20.04 zamiast ubuntu-latest, w obu jobach, z tego samego powodu co dziewięć miesięcy temu. latest przeskoczy kiedyś na kolejne LTS bez mojego udziału i chcę, żeby to była zmiana jednej linijki w commicie, a nie czerwony poniedziałek.
Krok docker version w jobie integracyjnym kosztuje sekundę, a odróżnia “nie ma demona” od “test się wywalił”. W jobie z Playwrightem go nie ma, bo tam Docker służy tylko do podniesienia aplikacji przez Compose i awarię widać od razu w kroku up.
Pętla czekająca na /health jest w Azure napisana przez {1..90}, a nie przez $(seq 1 90), i to nie jest kwestia gustu. $( ) w kroku script jest najpierw widziane przez podstawianie zmiennych Azure Pipelines, a dopiero potem przez powłokę. Mieszanie obu składni w jednej pętli to najkrótsza droga do kroku, który zachowuje się inaczej niż ten sam skrypt uruchomiony lokalnie.
condition: always() i if: always() przy artefaktach, bo raport z czerwonego przebiegu jest mi potrzebny bardziej niż z zielonego. To ta sama zasada, którą stosowałem przy PublishTestResults@2 w listopadzie.
Po stronie Playwrighta artefaktem jest katalog playwright-report z reportera HTML plus trace.zip dla testów, które padły. Trace mam ustawiony na on-first-retry, więc zielony przebieg nie kosztuje nic, a pierwszy czerwony zostawia komplet dowodów do otwarcia następnego dnia. Konfigurację tego pola opisałem w majowym wpisie o pierwszym projekcie i nic w niej na potrzeby CI nie zmieniam.
Pipeline z Cypressem, który pokazywałem dwa lata temu, robił połowę tego pliku: stawiał Node’a, uruchamiał przeglądarkę i kończył się czerwonym albo zielonym wynikiem. Nie było w nim ani jednej warstwy kontenerów podnoszonych z kodu testu, bo wtedy jej nie potrzebowałem. Różnica między tamtym YAML-em a tym nie jest różnicą między Cypressem a Playwrightem. Jest różnicą między “testuję UI wystawionej gdzieś aplikacji” a “cały stack pod testy wstaje w przebiegu”.
Kolejność, czekanie i flake
Trzy rzeczy, które w tym układzie generują losowe czerwone wyniki. Wszystkie trzy są o czasie.
Zimny pull. Agent hostowany dostaję świeży, więc obraz bazy jest pobierany od nowa w każdym przebiegu. To nie jest awaria, to jest cena wejścia, ale trzeba ją zmierzyć zamiast się nią zdziwić. Dlatego w jobie integracyjnym mam osobny krok docker pull przed testami: biblioteka pobrałaby obraz sama przy starcie kontenera, ale wtedy ten czas siedzi w środku dotnet test i nie da się go pokazać nikomu, kto pyta, dlaczego pipeline zwolnił.
Czekanie na aplikację. Krok z pętlą po curl wygląda topornie obok reszty pliku i celowo nie zamieniłem go na sleep 30. Stała pauza jest zawsze albo za krótka, albo za długa, i w obu przypadkach kłamie. Pętla po endpoincie zdrowia kończy się w sekundzie, w której aplikacja faktycznie odpowiada, a przy porażce wypluwa logi Compose’a do tego samego przebiegu, w którym szukam przyczyny. Jeżeli aplikacja nie ma /health, to jest to dobry moment, żeby ją o niego uzupełnić, a nie żeby zgadywać czas startu.
Wyścig w warstwie danych. Tego pipeline nie naprawia i nie udaję, że naprawia. Test integracyjny, który zapisuje do bazy i zaraz odczytuje, potrafi migotać niezależnie od tego, gdzie stoi kontener. Opisałem ten wzorzec osobno przy polityce ponawiania i jeżeli po przeniesieniu na CI widzę losową czerwień, sprawdzam najpierw jego, zanim zacznę obwiniać agenta.
Po stronie UI odpowiednikiem tego samego problemu jest pokusa, żeby podkręcić retries w konfiguracji Playwrighta do trzech i uznać sprawę za załatwioną. Nie robię tego. Web-first asercje same ponawiają sprawdzenie do timeoutu, więc czekanie na element jest już wbudowane w expect. Retry całego przebiegu jako strategia to nie jest stabilizacja, tylko chowanie wyniku, i wiem to od czasu, gdy rozkładałem równoległość na CI na czynniki pierwsze: najpierw izolacja, potem przyspieszanie.
Kolejność jobów też jest decyzją. dependsOn i needs ustawiłem tak, że Playwright rusza dopiero po zielonych testach integracyjnych. Nie dlatego, że jeden potrzebuje drugiego technicznie, tylko dlatego, że gdy pada warstwa danych, przebieg przez UI i tak powie mi to samo, dziesięć minut później i mniej precyzyjnie. Kiedy oba joby będą wystarczająco stabilne, przestawię je na równoległe i zapłacę za to dwoma raportami zamiast jednego.
Windows kontra Linux, znowu
Ta sekcja jest krótka, bo odpowiedź jest ta sama co w listopadzie, tylko pytanie przyszło z drugiej strony.
Playwright na windows-2019 działa bez zarzutu. Przeglądarki są, npx playwright install je pobiera, testy przechodzą. Może więc kusić, żeby cały pipeline postawić na Windowsie, skoro produkt i tak buduje się tam.
Testcontainers z obrazem linuksowej bazy na tym samym agencie nie ruszy. Demon Dockera pracuje w jednym trybie naraz, na hostowanym agencie Windows domyślnym trybem są kontenery Windows, a obraz MSSQL, który podnosi mój test, jest linuksowy. Pull kończy się komunikatem o niezgodności platformy i nie ma tu nic do skonfigurowania po stronie biblioteki.
Wniosek jest ten sam co wtedy: job z Testcontainers stoi na Linuksie. Jeżeli produkt musi budować się na Windowsie, to jest trzeci job w tym samym pliku, a nie powód, żeby przenosić tam testy.
Co świadomie zostawiam poza tym plikiem
Kilka rzeczy leżało w zasięgu ręki i nie weszły do tego YAML-a. Wypisuję je, żeby za pół roku było widać, że to były decyzje.
Host w pamięci procesu. Część testów, które dziś podnoszą kontener bazy, wcale go nie potrzebuje, bo sprawdza logikę warstwy API, a nie zapis do MSSQL. Da się takie rzeczy uruchamiać na hoście testowym w pamięci procesu, bez Dockera i bez sekundy zimnego pulla. To jest inny model niż wszystko w tym wpisie i zasługuje na własny tekst, którego jeszcze nie napisałem. Nie chcę zlewać obu technik w jeden tutorial, bo odpowiadają na różne pytania.
Visual regression. toHaveScreenshot() jest w Playwrighcie od maja i kusi, żeby dorzucić porównywanie obrazków do joba e2e. Nie robię tego dzisiaj, bo screenshoty na CI to rozmowa o progach tolerancji, o różnicach w renderowaniu między maszynami i o tym, kto zatwierdza nowe wzorce. Wrzucenie tego do istniejącego pipeline’u bez tych ustaleń da mi czerwone przebiegi bez informacji.
Component testing. Nadal jest oznaczone jako eksperymentalne i nie wpinam eksperymentów do pliku, od którego zależy merge do main.
Page objecty i architektura testów UI. W jobie e2e leci u mnie na razie garść smoke’ów. Kiedy urośnie, wróci pytanie o strukturę, które raz już rozstrzygałem przy refaktorze page objectów, i to jest temat na osobny wpis, nie na sekcję w tekście o YAML-u.
Podsumowanie
Trzy wnioski po złożeniu obu warstw w jednym pliku.
- Rozdzielenie na dwa joby wygrało z jednym długim, ale nie dlatego, że jest szybsze. Jest szybsze w czytaniu. Czerwony
integrationi czerwonye2eto dwie różne rozmowy w zespole i chcę je odróżniać z listy przebiegów, a nie z logu. - Kontener podniesiony w jednym jobie nie istnieje w drugim, więc każdy job odpowiada za własne środowisko. Ta jedna świadomość oszczędziła mi więcej czasu niż cała reszta tego wpisu.
- Model z listopada się nie zmienił. Docker siedzi na hoście agenta, job z Testcontainers stoi obok niego, a nie w środku innego kontenera. Zmieniło się tylko to, że obok tego joba stoi teraz drugi, z przeglądarką.
Czego nie wiem po tym miesiącu: czy e2e da się puścić równolegle z integration bez zwiększenia liczby migotliwych wyników, i ile z obecnych smoke’ów przeżyje pierwszą prawdziwą zmianę layoutu. Odpowiedź na oba pytania nie jest w YAML-u. Jest w tym, jak wygląda proces wokół testów, a decyzję o samym narzędziu podjąłem jeszcze w kwietniu i nadal jej nie żałuję.

