Wszystkie wpisy

Strategia testów w CI/CD - PR, main, nightly
Strategia testów w CI/CD - PR, main, nightly

English

Strategia testów w CI/CD - PR, main, nightly

Wrzesień 2022: które testy na PR, a które w nocy. Prędkość vs pewność po roku kontenerów. Izolacja i równoległość już są na blogu - tu trigger.

CI

Zielony PR, na który nikt nie czeka

Prawie rok temu opisywałem, jak zszedłem z czterdziestu minut regresji do jedenastu, dzieląc zestaw na cztery joby. Ta liczba była prawdziwa i mechanizm nadal działa. Problem w tym, że przez kolejne dwanaście miesięcy dokładałem do tego samego przebiegu następne rzeczy: kontener z bazą zamiast współdzielonej instancji, mock partnera zamiast czekania na jego środowisko, weryfikację kontraktów, drugą przeglądarkę w zestawie UI. Każda z nich osobno kosztowała kilka minut i każda osobno była warta swojej ceny.

Zsumowałem to w zeszłym tygodniu, bo chciałem wiedzieć, na co właściwie czekam po wypchnięciu brancha. Required checks na pull requeście chodzą u mnie trzydzieści cztery minuty. Jestem więc dokładnie tam, skąd rok temu uciekałem, tylko że za wyższą cenę: mam więcej jobów, więcej kontenerów i tę samą pętlę zwrotną.

Objawy są zawsze te same i nie dotyczą maszyn. Przy trzydziestu czterech minutach nikt nie siedzi i nie patrzy. Człowiek przełącza się na inne zadanie, wraca po godzinie, widzi jeden czerwony job, klika “re-run” bez czytania logu, bo przecież “to pewnie znowu ten test”. Jeżeli za drugim razem jest zielono, to nikt już nie sprawdzi, czy pierwszy przebieg mówił prawdę. Zestaw testów, który dostaje takie traktowanie, przestaje być bramką, a zaczyna być formalnością.

Kuszące jest dorzucenie kolejnych czterech jobów, bo to jedna linijka w YAML-u. Tym razem nie chcę iść tą drogą. Dzielenie na plasterki skraca wall-clock proporcjonalnie do liczby maszyn i tylko do momentu, w którym koszt stały joba zaczyna zjadać zysk; policzyłem to rok temu i nic się w tej arytmetyce nie zmieniło. Zostaje drugie pytanie, którego wtedy sobie nie zadałem: czy ten test w ogóle musi się wykonać przed merdżem.

Ten wpis jest o tym pytaniu. Nie o narzędziach, bo narzędzia mam wybrane: .NET 6, xUnit 2.4.2 z 1 sierpnia albo NUnit 3.13.3, kontenery, Compose, przeglądarki. Wszystkie zostają. Zmieniam wyłącznie to, na jakim triggerze każde z nich odpalam.

Trzy koszyki: PR, main, nightly

Wypisałem sobie wszystko, co dzisiaj odpala CI, i rozdzieliłem to na trzy koszyki po jednym kryterium: co się stanie, jeżeli ten test padnie dziesięć minut po merdżu zamiast dziesięć minut przed.

Koszyk pierwszy, pull request. Tu wchodzi to, co potrafi zablokować merdż i nie kłamie. Build i analiza, testy jednostkowe, testy kontraktowe po stronie konsumenta oraz jeden smoke, który sprawdza, czy aplikacja w ogóle wstaje i odpowiada na jednym istotnym scenariuszu. Kontrakty konsumenta są tu, bo są tanie: test pisze plik paktu z mocka po stronie klienta, nie potrzebuje ani sieci, ani żywego dostawcy. Cel tego koszyka to nie “pełna pewność”, tylko minuty. Jeżeli coś nie mieści się w kilku minutach i nie jest deterministyczne, to nie jest kandydatem do required check, choćby było najmądrzejszym testem w repozytorium.

Koszyk drugi, main po merdżu. Tutaj przenoszę wszystko, co potrzebuje prawdziwych zależności: integrację z bazą w kontenerze, kolejkę, weryfikację kontraktu po stronie dostawcy, szerszy zestaw API. Ten przebieg ma prawo trwać kwadrans i ma prawo czasem być czerwony. Czerwony main jest zdarzeniem, na które ktoś reaguje od razu, ale nie kosztuje nikogo oczekiwania przy otwartym pull requeście. Świadomie godzę się tu na to, że wadliwy commit może przez chwilę siedzieć na głównej gałęzi. Alternatywą jest trzymanie każdego autora czterdzieści minut przy każdej literówce w README.

Koszyk trzeci, nocny harmonogram. Pełne E2E przez przeglądarkę, macierz silników, wolne scenariusze na większych danych, długie ścieżki biznesowe. To jest zestaw, który daje najwięcej wiedzy o produkcie i najgorzej nadaje się na bramkę: jest wolny, chodzi przez interfejs, który zmienia się częściej niż kontrakty, i zależy od stanu środowiska. Nocny przebieg nie jest required check na pull requeście i nie ma być.

Szkic tego podziału mieści się w trzech jobach. Poniżej celowo bez checkoutu, instalacji SDK i publikacji wyników, żeby widać było same triggery:

name: tests

on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *'

jobs:
  pr-gate:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-20.04
    steps:
      - run: dotnet test tests/Unit -c Release --no-build
      - run: dotnet test tests/Contracts.Consumer -c Release --no-build
      - run: dotnet test tests/Smoke -c Release --no-build --filter "Category=smoke"

  integration:
    if: github.event_name == 'push'
    runs-on: ubuntu-20.04
    steps:
      # kontenery zaleznosci startuja z poziomu testu
      - run: dotnet test tests/Integration -c Release
      - run: dotnet test tests/Contracts.Provider -c Release

  nightly:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-20.04
    strategy:
      fail-fast: false
      matrix:
        browser: [chromium, firefox, webkit]
    steps:
      - run: npm run e2e -- --project=${{ matrix.browser }}

Trzy rzeczy w tym szkicu robią całą robotę i żadna nie jest sztuczką składniową. Po pierwsze, pr-gate jest jedynym jobem, który wpinam w politykę gałęzi jako wymagany; pozostałe dwa mogą świecić na czerwono i nikomu nie zablokują merdża. Po drugie, każdy koszyk ma osobny katalog projektu testowego, bo podział, którego nie widać w strukturze repozytorium, po miesiącu przestaje istnieć. Po trzecie, nocna macierz ma fail-fast: false, bo z nocnego przebiegu chcę pełnego obrazu, a nie pierwszej porażki; mechanikę macierzy rozpisywałem przy równoległych testach na CI i tutaj tylko z niej korzystam.

Ten sam podział zapisuje się w Azure Pipelines równie krótko: pr i trigger w pliku YAML plus osobny pipeline ze schedules. Nazwy się różnią, decyzja jest identyczna.

Prędkość kontra pewność

Kiedy przenosiłem testy między koszykami, najbardziej pomogło mi jedno przeformułowanie. Każda warstwa ma inną cenę flaka i dopiero to mówi, gdzie test powinien mieszkać.

Migotliwy test na pull requeście kosztuje najwięcej ze wszystkich, bo psuje nie jeden przebieg, tylko nawyk. Człowiek widzi czerwony required check, wie z doświadczenia, że w połowie przypadków to nieprawda, i przestaje czytać. Od tego momentu bramka nie chroni już przed niczym, bo nauczyła zespół, że jej wynik jest opcjonalny. Dlatego do pierwszego koszyka wpuszczam tylko to, co jest deterministyczne, i wyrzucam stamtąd wszystko, co “prawie zawsze” przechodzi.

Ten sam flak na nocnym przebiegu kosztuje jedno zgłoszenie do zbadania rano. To jest cena, którą chętnie płacę, bo w zamian dostaję test, który chodzi przez prawdziwy interfejs i prawdziwe środowisko. Nocny zestaw ma prawo być nerwowy, o ile ktoś rano na niego patrzy.

Z tego wynika reguła, którą przypinam do siebie, bo mam wobec niej pokusę. Ponawianie nie jest przepustką z nocy na pull request. Polityki retry na Polly opisywałem rok temu i nadal ich używam, ale one są od chwilowych usterek sieci i cudzych usług, a nie od maskowania wyścigu o dane. Owinięcie testu w drugą próbę nie zmienia jego kosztu ani jego wiarygodności, zmienia tylko to, czy widzę problem. Test, który wymaga trzech prób, żeby zmieścić się w bramce, po prostu nie należy do bramki.

Warto też powiedzieć wprost, czego pull request nie udowadnia. Zielony pr-gate nie znaczy “gotowe do wydania”. Znaczy dokładnie tyle: ta zmiana nie łamie jednostek, nie łamie kontraktu z konsumentami i aplikacja po niej wstaje. Reszta pewności przychodzi później i to jest świadoma decyzja, a nie luka, którą zapomniałem załatać.

Czego tu nie powtarzam

Trzy rzeczy z tego wpisu są już na blogu i nie zamierzam ich pisać drugi raz.

Izolacja i trzy poziomy równoległości: wątki frameworka, procesy VSTest i joby pipeline’u. Kolejność z tamtego wpisu nadal obowiązuje i jest warunkiem wstępnym całej dzisiejszej dyskusji. Podział po triggerze na źle izolowanym zestawie da dokładnie to samo, co równoległość na źle izolowanym zestawie, czyli szybsze flaki. Gdzie mieszka stan klasy testowej i fixture’a, rozpisałem w styczniu przy organizacji testów w xUnit i NUnit.

Kontener na agencie: Testcontainers w Azure Pipelines z listopada, razem z socketem i wyborem między jobem kontenerowym a demonem hosta. Ten wpis tłumaczy jak. Dzisiejszy mówi tylko, że ciężka integracja z kontenerem należy do main i do nocy, a nie do każdego pull requesta.

Przeglądarka na CI: Cypress na Azure Pipelines. Też zostaje bez zmian. Zmienia się jedno zdanie: pełna macierz przeglądarek nie jest wymaganym checkiem na pull requeście.

Dane wciąż są osobnym tematem i wciąż ich nie mam idealnie poukładanych; strategie zarządzania danymi testowymi opisywałem we wrześniu zeszłego roku i to jest wiedza, na której stoi cały koszyk drugi. Bez własnych danych na przebieg job main z kilkoma równoległymi plasterkami zamienia się w loterię.

Kontenery po roku

Rok z Testcontainers i Compose zmienił mi jedną rzecz w sposobie liczenia. Kontener nie jest darmowy przez to, że uruchamia się z poziomu kodu.

Zimny start bazy w kontenerze na świeżym agencie to u mnie nadal grube kilkadziesiąt sekund, zanim serwer w ogóle zacznie odpowiadać, plus czas pobrania obrazu, jeżeli agent go nie ma. Przy jednym teście integracyjnym to szum, przy każdym pull requeście to podatek płacony przez wszystkich, także przez autora zmiany w pliku konfiguracyjnym. Dlatego integracja z prawdziwymi zależnościami siedzi u mnie w koszyku drugim: raz na merdż, nie raz na push.

Wersje na dzisiaj, żeby wpis się nie rozjechał z rzeczywistością: DotNet.Testcontainers w 1.6.0 z 7 czerwca na jobie main, kontenery odpalane z testu tak samo jak w pierwszym podejściu do MSSQL. Tam, gdzie zależności jest kilka i chcę je mieć opisane deklaratywnie, nadal robi to Compose.

Jedna nota, bez robienia z niej tematu wpisu. Compose V2 jest ogólnie dostępny od 26 kwietnia tego roku i wołam go bez myślnika, czyli docker compose. Moje wpisy o Dockerze dla QA i o środowisku testowym na Compose są z zeszłego roku i uczą wariantu z myślnikiem. Nie przepisuję ich i nie robię migracji na siłę; na nowo zakładanym jobie po prostu piszę wersję dwa i tyle. To jest zmiana nazwy komendy, nie zmiana strategii.

UI we wrześniu

Testy przez przeglądarkę są tym miejscem, gdzie podział na koszyki daje największy zysk, bo są jednocześnie najcenniejsze i najdroższe.

Reguła jest prosta: pełna macierz idzie do nocy, na pull request wchodzi co najwyżej jeden scenariusz. Ten jeden musi spełniać ostry warunek - własny stan, własne dane, brak zależności od tego, co zostawił poprzedni przebieg. Jeżeli smoke UI wymaga, żeby na środowisku leżało konto założone w zeszłym tygodniu, to nie jest smoke, tylko bomba zegarowa. Selenium Grid w Dockerze z zeszłego roku zostaje narzędziem od macierzy, a nie od pojedynczego scenariusza na push; do jednego smoke’a wystarczy headless w tym samym jobie.

Po stronie narzędzi wrzesień zastaje mnie w rozkroku i nie udaję, że jest inaczej. Seria o Cypressie na blogu opisuje zestaw, który realnie u mnie chodzi, i nic z niego dzisiaj nie znika. Równolegle wiosną opisałem, dlaczego zaczynam schodzić z Cypressa, i mam z tego pusty projekt postawiony w maju; paczka jest dzisiaj w 1.25.0 z 11 sierpnia. Dla tego wpisu to nie ma większego znaczenia i to jest właśnie sedno: podział na koszyki nie zależy od tego, który runner wygra. Cokolwiek u mnie zostanie za rok, pełna macierz przeglądarek i tak będzie chodziła w nocy, a nie na bramce.

Sygnał, nie hałas

Podział po triggerze ma sens tylko wtedy, kiedy każdy koszyk ma swojego odbiorcę. Inaczej po miesiącu nocny przebieg staje się tapetą.

Publikacja wyników jest tu warunkiem, nie ozdobą. Wyniki z każdego przebiegu lądują jako artefakty i raport, tak jak przy publikowaniu wyników Cypressa, a nocny przebieg dodatkowo wysyła wiadomość na Slacka po zakończonym jobie. Różnica jest w treści: z pull requesta interesuje mnie wyłącznie czerwony wynik, z nocy interesuje mnie także seria zielonych, bo dopiero na tle serii widać, który test migocze.

Druga zasada dotyczy listy wymaganych checków i jest mniej oczywista, niż wygląda. Wymagany check ma być mały i stabilny. Kiedy do polityki gałęzi wpisze się wszystko, co pipeline potrafi uruchomić, to lista przestaje cokolwiek znaczyć: każdy czerwony element jest tak samo ważny, więc żaden nie jest ważny, a ludzie i tak wybierają, na który patrzą. Trzymam więc krótką listę i traktuję każde dopisanie do niej jak zmianę procesu, a nie jak zmianę konfiguracji.

Trzecia rzecz to właściciel nocnego przebiegu. Bez nazwiska w kalendarzu ten koszyk umiera w trzy tygodnie. Nie ma tu żadnej flagi w YAML-u, jest tylko ustalenie, że rano ktoś patrzy i albo zakłada zgłoszenie, albo wyrzuca test.

Podsumowanie

Kolejność jest ważniejsza niż konkretna składnia, więc zapisuję ją na koniec.

Najpierw izolacja, bo bez niej wszystko poniżej produkuje szybszy szum. Potem podział po miejscu w git: pull request bierze jednostki, kontrakty konsumenta i jeden smoke, main bierze integrację z prawdziwymi zależnościami i weryfikację po stronie dostawcy, noc bierze pełne E2E i macierz. Dopiero na końcu równoległość, i to osobno w każdym koszyku, bo każdy ma inny budżet.

Mierzę przy tym dwie liczby i celowo nie sumuję ich w jedną. Pierwsza to wall-clock samych required checks, bo ona decyduje, czy ktoś jeszcze czeka na wynik. Druga to odsetek nocnych przebiegów, w których coś padło bez zmiany w kodzie, bo ona decyduje, czy poranne patrzenie na raport ma sens. Poprawianie pierwszej kosztem drugiej jest łatwe i zawsze kończy się tak samo.

U mnie po przełożeniu koszyków bramka na pull requeście zeszła z trzydziestu czterech minut do siedmiu, a suma czasu maszyn wcale nie spadła, bo te same testy nadal się wykonują, tylko gdzie indziej. To jest cała transakcja: nie zrobiłem nic szybciej, przesunąłem czekanie z człowieka na harmonogram. Wracam z tym do pytania, które zadaję sobie za każdym razem, kiedy dłubię w pipelinie: gdzie to siedzi w całym procesie testowym i czy w zeszłorocznym podsumowaniu obiecywałem sobie coś innego. Szybsza bramka nie poprawia jakości. Sprawia tylko, że ludzie znowu czytają jej wynik.