Wszystkie wpisy

Testcontainers i Playwright w jednym pipeline
Testcontainers i Playwright w jednym pipeline

English

Testcontainers i Playwright w jednym pipeline

Sierpień 2022: NuGet Testcontainers 2.1.0 i Playwright 1.25 w jednym YAML (Azure Pipelines albo GitHub Actions). Docker na hoście, bez pułapki container job z listopada 2021.

PlaywrightCI.NET

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-deps

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

Ten 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 integration i czerwony e2e to 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ę.