Czterdzieści minut na jednym agencie
Mój zestaw regresji ma dzisiaj około dwustu testów. Część uderza w API, część chodzi przez przeglądarkę, wszystko na .NET 5 i NUnit 3.13.2. Lokalnie odpalam wybraną klasę i mam wynik w kilkanaście sekund. Na CI cały przebieg zajmuje czterdzieści minut, bo agent bierze testy jeden po drugim i nikomu się nie spieszy.
Czterdzieści minut to jest ta granica, po przekroczeniu której ludzie przestają czekać na zielone. Zaczynają mergować “bo pewnie przejdzie”, a wynik oglądają dopiero, kiedy ktoś zgłosi, że na środowisku coś nie działa. Pętla zwrotna przestaje istnieć nie dlatego, że testy są złe, tylko dlatego, że przychodzą za późno.
Chcę ten czas skrócić i od razu zapisuję sobie warunek brzegowy: skracam wall-clock, a nie liczbę czerwonych wyników. Równoległość natychmiast wynagradza bałagan w izolacji migotliwymi testami. Jeżeli po włączeniu czterech jobów zestaw chodzi dziesięć minut, ale raz na trzy przebiegi jeden test pada bez powodu, to zamieniłem powolny sygnał na szybki szum.
Na marginesie: Selenium 4.0.0 wyszło dwa dni temu, 13 października, ja siedzę jeszcze na Selenium.WebDriver 3.141.0 i ten wpis nie jest o migracji sterownika.
Trzy poziomy równoległości
Pierwsza rzecz, którą musiałem sobie uporządkować: “równoległe testy” znaczą trzy różne rzeczy, a mieszanie ich w jednej rozmowie kończy się włączeniem flagi, która nie robi tego, co ktoś myślał.
Poziom pierwszy to wątki w jednym procesie. Decyduje o tym framework testowy: NUnit albo xUnit. Testy lecą równocześnie w tej samej domenie aplikacji, więc dzielą pamięć, otwarte połączenia i wszystko, co komuś przyszło do głowy zadeklarować jako static.
Poziom drugi to wiele procesów na jednej maszynie. Tym zarządza platforma testowa, czyli VSTest: uruchamia osobny host testowy na rdzeń, a jednostką podziału jest assembly. Procesy nie dzielą pamięci, ale dzielą maszynę: porty, system plików, zmienne środowiskowe, lokalny Docker.
Poziom trzeci to wiele maszyn, czyli joby pipeline’u. Tutaj nie dzielimy już prawie niczego poza zasobami zewnętrznymi, na przykład wspólną bazą na środowisku testowym. Za to każdy job kosztuje: czas agenta, powtórzony restore, build i docker pull.
Microsoft opisuje poziomy drugi i trzeci w dokumentacji Azure Pipelines pod hasłami “parallel testing” dla taska VSTest oraz “Run any tests in parallel”. Poziom pierwszy siedzi w dokumentacji frameworka i to o nim najczęściej zapomina się na starcie.
Te poziomy się mnożą, nie dodają: cztery joby po cztery wątki frameworka to szesnaście testów jednocześnie. Każdy psuje się inaczej, więc włączam je pojedynczo, w tej kolejności co powyżej, i po każdym patrzę na flake rate.
NUnit vs xUnit: kto jest równoległy domyślnie
Domyślne zachowanie tych dwóch frameworków jest dokładnie odwrotne i to jest chyba najczęstsza pułapka przy zmianie runnera.
NUnit nie robi nic równolegle, dopóki go o to nie poprosisz: mogę mieć osiem rdzeni i pusty pipeline, testy i tak pójdą po kolei. Włącza się to atrybutem [Parallelizable] na poziomie assembly, klasy albo metody:
// dowolny plik w projekcie testowym, np. AssemblyInfo.cs
using NUnit.Framework;
[assembly: Parallelizable(ParallelScope.Fixtures)]
[assembly: LevelOfParallelism(4)]ParallelScope.Fixtures znaczy: klasy testowe mogą chodzić równolegle względem siebie, ale testy wewnątrz jednej klasy nadal lecą po kolei. To jest mój pierwszy krok w każdym projekcie, bo daje sensowny zysk i nie wymaga, żeby każdy test w klasie był niezależny od sąsiada. ParallelScope.All puszcza równolegle także metody i tam trafiam dopiero wtedy, kiedy jestem pewien izolacji. LevelOfParallelism ustawia rozmiar puli wątków; bez niego NUnit bierze liczbę procesorów, a agent CI i mój laptop nie mają tyle samo rdzeni.
xUnit 2.4.1 działa odwrotnie: kolekcje testowe chodzą równolegle od początku, bez żadnego atrybutu. Domyślnie jedna klasa to jedna kolekcja, więc z pudełka mam równoległe klasy i szeregowe testy w klasie, czyli to, co w NUnit dostaję po dopisaniu ParallelScope.Fixtures. Liczbę wątków ustawia się w xunit.runner.json:
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}I tu jest pułapka migracji, na którą sam kiedyś wpadłem. Zestaw napisany pod NUnit bez atrybutów jest z definicji szeregowy, więc wolno mu mieć statyczny WebDriver i wspólny stan. Przeniesienie tego samego kodu na xUnit włącza równoległość samym faktem zmiany biblioteki: testy padają w losowych miejscach, a w commicie nie ma ani jednej linii o wątkach. W drugą stronę jest łagodniej, ale też myląco: ktoś przechodzi na NUnit, dopisuje parallel: 4 w pipelinie i dziwi się, że nic nie przyspieszyło, bo framework nadal czeka na [Parallelizable].
Zasada z tego jest jedna: zanim dotknę pipeline’u, muszę wiedzieć, ile testów naprawdę chodzi jednocześnie w jednym procesie. Tę liczbę ustawiam świadomie, a nie dziedziczę po domyślnych ustawieniach biblioteki.
Jedna maszyna: VSTest i /parallel
Drugi poziom to VSTest odpalający wiele hostów testowych na jednym agencie. Steruje tym MaxCpuCount w .runsettings albo przełącznik /Parallel w vstest.console:
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<RunConfiguration>
<MaxCpuCount>0</MaxCpuCount>
<TargetFrameworkVersion>net5.0</TargetFrameworkVersion>
<ResultsDirectory>./TestResults</ResultsDirectory>
</RunConfiguration>
</RunSettings>MaxCpuCount na 0 znaczy “tyle hostów, ile rdzeni logicznych”, 1 wyłącza równoległość. Plik podaję do dotnet test --settings tests/ci.runsettings, a w Azure Pipelines to samo w tasku wygląda tak:
- task: VSTest@2
inputs:
testAssemblyVer2: |
**/*.Tests.dll
!**/obj/**
runSettingsFile: 'tests/ci.runsettings'
runInParallel: true
platform: 'x64'
configuration: 'Release'Jedna rzecz, żeby się nie zdziwić: przy runInParallel: true task sam pilnuje liczby hostów, a MaxCpuCount z .runsettings przestaje mieć znaczenie. Nie warto dłubać w obu miejscach naraz.
Teraz najważniejsze zastrzeżenie tej sekcji, bo tu najczęściej gubi się parę godzin. Jednostką podziału VSTest jest assembly. Jeżeli wszystkie testy mieszkają w jednym Shop.UiTests.dll, to runInParallel nie ma czego rozłożyć: dostaję jeden host testowy i dokładnie ten sam czas co wcześniej. Zysk pojawia się przy kilku projektach testowych, osobnym dla API, osobnym dla UI i osobnym dla warstwy danych. Wewnątrz jednego assembly przyspiesza mnie tylko poziom pierwszy, czyli [Parallelizable].
Drugie zastrzeżenie: to wszystko dzieje się na jednej maszynie, bo VSTest nie rozdziela testów między agentami. Żeby zejść z czterdziestu minut do dziesięciu, potrzebuję czterech maszyn, a nie czterech procesów na jednej.
Wiele agentów w Azure Pipelines: plasterki
W Azure Pipelines job da się rozmnożyć jedną linijką:
jobs:
- job: functional
displayName: 'Testy funkcjonalne'
strategy:
parallel: 4
pool:
vmImage: 'ubuntu-20.04'
steps:
- task: UseDotNet@2
inputs:
packageType: sdk
version: '5.0.x'
- script: dotnet build --configuration Release
displayName: 'Build'
- script: bash tests/slice.sh
displayName: 'Plasterek $(System.JobPositionInPhase) z $(System.TotalJobsInPhase)'
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
mergeTestResults: truestrategy: parallel: 4 daje mi cztery identyczne joby, czyli każdy chce uruchomić cały zestaw testów. To jest ta pułapka: rozmnożenie joba nie skraca niczego, tylko robi to samo cztery razy. Podział muszę zrobić sam, a Azure daje mi do tego dwie zmienne: System.JobPositionInPhase z numerem joba od 1 do N i System.TotalJobsInPhase z liczbą jobów.
Mój tests/slice.sh wygląda tak:
#!/usr/bin/env bash
set -euo pipefail
INDEX="${SYSTEM_JOBPOSITIONINPHASE:-1}"
TOTAL="${SYSTEM_TOTALJOBSINPHASE:-1}"
PROJECT="tests/Shop.UiTests/Shop.UiTests.csproj"
# lista klas testowych: pelne nazwy, bez argumentow, bez nazwy metody
dotnet test "$PROJECT" -c Release --no-build --list-tests \
| sed -n 's/^ \(.*\)$/\1/p' \
| sed 's/(.*$//' \
| sed 's/\.[^.]*$//' \
| sort -u > all-classes.txt
awk -v i="$INDEX" -v n="$TOTAL" '(NR - 1) % n == (i - 1)' all-classes.txt > slice.txt
FILTER=$(awk '{printf "%sFullyQualifiedName~%s", (NR > 1 ? "|" : ""), $0}' slice.txt)
if [ -z "$FILTER" ]; then
echo "Plasterek $INDEX z $TOTAL jest pusty - nic do uruchomienia."
exit 0
fi
echo "Plasterek $INDEX z $TOTAL, klas: $(wc -l < slice.txt)"
dotnet test "$PROJECT" -c Release --no-build \
--filter "$FILTER" \
--logger "trx;LogFileName=slice-$INDEX.trx"Cztery decyzje są tu celowe i każda wynika z czegoś, co mi się wysypało.
Dzielę klasami, nie pojedynczymi testami. dotnet test --list-tests wypisuje pełne nazwy razem z argumentami przypadków, czyli Shop.UiTests.CartTests.AddsProduct(1,"PLN"), a wstawienie takiej nazwy do --filter FullyQualifiedName=... kończy się awanturą o nawiasy i przecinki. Obcinam więc argumenty i nazwę metody, a filtruję operatorem ~, czyli “zawiera”. Efekt uboczny jest korzystny: klasa zostaje w jednym plasterku, więc [OneTimeSetUp] wykonuje się raz, a nie w każdym jobie.
Filter jest przez to krótki. Dwieście pełnych nazw w jednej linii komendy to kilkanaście kilobajtów argumentu i realne ryzyko obcięcia; kilkanaście nazw klas mieści się bez problemu.
Pusty plasterek nie jest błędem. Przy trzech klasach i czterech jobach jeden job nie ma nic do roboty i musi skończyć się na zielono, a nie wywalić na braku testów.
Podział round-robin po klasach jest głupi, ale przewidywalny. Rozkłada liczbę klas równo i nie zna czasu wykonania, więc jeżeli jedna klasa chodzi osiem minut, a pozostałe po pół minuty, to wall-clock wyznacza ta jedna. Dlatego zaglądam w czasy z plików .trx i przenoszę ciężkie klasy ręcznie, zamiast dodawać kolejny job.
Alternatywa: task VSTest@2 umie pociąć testy sam. Ma do tego distributionBatchType z trzema trybami: po liczbie przypadków, po czasie wykonania z poprzednich przebiegów i po assembly. Tryb czasowy rozwiązuje dokładnie ten problem nierównych klas i jest lepszy od mojego awk. Zostawiam to taskowi, gdy testy są zwykłymi assembly, które sam wykrywa, a rozkład ma być automatyczny. Filtruję ręcznie, gdy chcę mieć podział jawny w repozytorium, gdy dzielę po kategoriach zamiast po liczbie, albo gdy uruchamiam coś, czego VSTest nie wykrywa jako testu.
GitHub Actions: strategy.matrix i max-parallel
W GitHub Actions ten sam pomysł zapisuje się macierzą:
name: functional-tests
on:
pull_request:
push:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 4
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.x'
- run: dotnet build --configuration Release
- name: Testy - plasterek ${{ matrix.shard }}
env:
SYSTEM_JOBPOSITIONINPHASE: ${{ matrix.shard }}
SYSTEM_TOTALJOBSINPHASE: 4
run: bash tests/slice.sh
- uses: actions/upload-artifact@v2
if: always()
with:
name: trx-shard-${{ matrix.shard }}
path: '**/TestResults/*.trx'Ten sam slice.sh obsługuje oba pipeline’y, bo numer i liczbę plasterków dostaje przez zmienne środowiskowe o nazwach z Azure. Brzydkie, ale mam jeden skrypt do utrzymania i odpalam go lokalnie, podstawiając te same dwie zmienne.
fail-fast: false jest tu obowiązkowe. Domyślnie macierz przy pierwszej porażce anuluje pozostałe joby, co ma sens przy budowaniu na pięciu platformach, a przy testach jest szkodliwe: chcę wiedzieć, czy padł jeden test w jednym plasterku, czy dwadzieścia w czterech, bo to zupełnie inna diagnoza.
max-parallel ogranicza, ile jobów macierzy chodzi jednocześnie. Przydaje się, kiedy plasterków jest więcej niż zasobów albo kiedy testy uderzają we wspólne środowisko, które nie zniesie dwudziestu klientów. Górny limit macierzy to 256 jobów na przebieg workflow i nigdy się do niego nie zbliżyłem.
Jeżeli testy są już potagowane, zamiast numerowanych plasterków można rozłożyć macierz po kategoriach, czyli suite: [smoke, cart, checkout, admin] plus --filter "TestCategory=${{ matrix.suite }}", co dla NUnit odpowiada atrybutowi [Category("cart")]. Lubię ten wariant za czytelność, bo w wynikach widzę nazwy, a nie cyfry. Wadą jest nierówny rozmiar kategorii, którego nikt automatycznie nie wyrówna.
Osobno wyprostuję jedno nieporozumienie, bo słyszałem je już dwa razy. Klucz concurrency, który GitHub dodał w kwietniu tego roku, nie ma nic wspólnego z dzieleniem testów. Ogranicza nakładające się przebiegi workflow albo jobów w jednej grupie i potrafi anulować ten w toku. Używam go, żeby dwa pushe pod rząd nie odpaliły dwóch deployów na to samo środowisko:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: trueTo jest bezpiecznik przed nadmiarem przebiegów, a nie mechanizm plasterków. Za plasterki odpowiada wyłącznie matrix.
Jeszcze drobiazg o obrazie: ubuntu-latest jest aliasem i dzisiaj wskazuje na 20.04, ale kiedyś przesunie się bez mojego udziału. Tam, gdzie zależy mi na powtarzalności, wpisuję wersję wprost, tak jak przypinam tagi obrazów od stycznia w Dockerze dla QA.
Izolacja albo flaki
Tu jest prawdziwa treść tego wpisu. YAML wyżej to dwadzieścia linijek, a tydzień pracy zajęło mi to, co poniżej.
Statyczny WebDriver jest zabójcą numer jeden. private static IWebDriver _driver przechodzi wszystkie testy szeregowe i wysypuje się natychmiast po włączeniu równoległości, bo dwie klasy sterują jedną przeglądarką i wygrywa ta, która pierwsza zdąży kliknąć. Lekarstwem jest pole instancyjne plus świeża instancja klasy na test, co NUnit umie od wersji 3.13 ze stycznia:
using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
[TestFixture]
[Parallelizable(ParallelScope.Self)]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class CartTests
{
private IWebDriver _driver;
[SetUp]
public void SetUp()
{
var options = new ChromeOptions();
options.AddArgument("--headless");
options.AddArgument("--window-size=1280,900");
_driver = new ChromeDriver(options);
}
[TearDown]
public void TearDown()
{
_driver?.Quit();
_driver?.Dispose();
}
}LifeCycle.InstancePerTestCase znaczy, że NUnit tworzy nowy obiekt klasy testowej dla każdego testu, więc pola instancyjne nie przeciekają między testami nawet przez przypadek; domyślnie jest odwrotnie, czyli jedna instancja na całą klasę. Jedno ograniczenie warto zapamiętać: [OneTimeSetUp] i [OneTimeTearDown] muszą być wtedy statyczne, co jest zresztą zdrowe, bo od razu widać, co jest wspólne dla całej klasy.
To jest ta sama myśl, którą wałkowałem przy refaktoryzacji testów w Cypressie: test sam ustawia sobie stan i nie zakłada, że ktoś zrobił to przed nim. Wtedy chodziło o czytelność, przy równoległości to samo założenie decyduje o tym, czy pipeline jest zielony.
Wspólny port to zabójca numer dwa. W maju postawiłem WireMocka jako mock API partnera na sztywnym porcie 58090, a w środowisku z Compose tak samo przypiąłem bazę i cache. Dopóki na agencie stoi jedno środowisko, wszystko jest w porządku; dwa joby na tej samej maszynie i drugi dostaje “address already in use”. Kontener uruchamiany na jobie potrzebuje więc własnych portów i własnych danych, a nie tych z README. Przesuwam porty numerem plasterka i nadaję projektowi Compose osobną nazwę:
export COMPOSE_PROJECT_NAME="shard-${INDEX}"
export MOCK_PORT=$((58090 + INDEX))
export DB_PORT=$((55432 + INDEX))
docker-compose up -dNazwa projektu jest tu ważniejsza, niż wygląda: bez niej Compose uznaje, że to ten sam projekt, i drugi job przejmuje kontenery pierwszego. Kiedy chcę pełnej pewności co do portów, sięgam po podejście z kwietnia, czyli Testcontainers z portem mapowanym przez bibliotekę, i pytam kontener, na czym stanął.
Zabójca numer trzy to wspólna baza. Dwa plasterki liczące na to samo konto testowe albo tę samą tabelę słownikową będą sobie kasować dane pod nogami. Nie ma tu magicznej flagi w YAML-u, jest tylko decyzja: albo każdy job dostaje własną bazę lub schemat, albo testy tworzą własne dane z unikalnym prefiksem i sprzątają wyłącznie po sobie. Zapytania w stylu DELETE FROM Orders w [TearDown] przestają być dopuszczalne w momencie, w którym włączam drugi job. To samo dotyczy katalogów: zrzuty ekranu, pliki .trx i katalog pobierania dostają u mnie numer plasterka w nazwie.
Jeżeli rozłożyć trzeba przeglądarki, a nie testy, to jest do tego druga oś, czyli Selenium Grid w Dockerze z marca. Kiedy jednak każdy job odpala własnego headless Chrome’a u siebie, Grid przestaje być potrzebny.
I zdanie, które muszę powiedzieć wprost, bo sam mam pokusę: ponawianie to nie izolacja. W lipcu opisywałem polityki ponawiania na Polly i nadal się ich trzymam, ale one są od chwilowych usterek sieci i zewnętrznych usług. Owinięcie retry wokół testu, który przegrał wyścig z sąsiadem o wiersz w bazie, nie naprawia niczego. Zamienia czerwony wynik na zielony po drugiej próbie, czyli usuwa jedyny sygnał, że dwa testy dzielą stan, którego nie powinny dzielić.
Ile to kosztuje
Równoległość nie jest darmowa i to nie jest wyłącznie kwestia faktury.
Po pierwsze, liczba jobów, które faktycznie chodzą jednocześnie, zależy od planu, na jakim jest konto. Macierz z dwudziestoma plasterkami przy niższym limicie równoległych jobów ustawi się w kolejce, a wall-clock spadnie tyle razy, ile jobów naprawdę wystartuje razem. Konkretne liczby trzeba sprawdzić w dokumentacji swojego planu w dniu, w którym się to liczy, bo to jest rzecz, która się zmienia.
Po drugie, w Azure Pipelines równoległe joby są osobnym zasobem. Dla nowych organizacji darmowy przydział przestał w tym roku być przyznawany automatycznie: zmiana dla projektów publicznych weszła w lutym, dla prywatnych w marcu, i trzeba wypełnić wniosek. Organizacje, które przydział już miały, zostały bez zmian. Praktyczny wniosek: parallel: 8 w świeżej organizacji może oznaczać osiem jobów w kolejce po jeden wolny slot, a w logu nie zobaczę żadnego błędu, tylko powolny przebieg.
Po trzecie, każdy job ma koszt stały: checkout, instalacja SDK, restore, build, docker pull. Przy czterech jobach dziesięć minut testów plus cztery minuty rozgrzewki to świetny interes. Przy szesnastu mam po dwie i pół minuty testów i wciąż cztery minuty rozgrzewki, czyli płacę za szesnaście maszyn, żeby zejść z czternastu minut do sześciu. Dlatego zanim dodam kolejny plasterek, zbijam koszt stały: build raz w osobnym jobie, binaria jako artefakt, joby testowe tylko je pobierają i wołają dotnet test --no-build. Cache na paczki NuGet jest w obu systemach.
Podsumowanie
Kolejność ma tu znaczenie większe niż same narzędzia, więc zapisuję ją na koniec.
Najpierw izolacja: żaden statyczny stan, żaden sztywny port, żadna wspólna baza między plasterkami. Bez tego cała reszta produkuje flaki, tylko szybciej. Potem poziom frameworka, bo jest najtańszy: [Parallelizable(ParallelScope.Fixtures)] w NUnit z jawnie ustawionym LevelOfParallelism, albo świadome przyjęcie tego, co xUnit robi domyślnie. Potem wiele procesów na agencie, jeżeli mam kilka projektów testowych, bo tylko wtedy runInParallel ma co dzielić. Na końcu wiele jobów: strategy: parallel z plasterkami po System.JobPositionInPhase w Azure albo strategy.matrix z fail-fast: false w GitHub Actions.
I dwie liczby, które od dzisiaj notuję po każdym takim ćwiczeniu: wall-clock całego przebiegu, bo on decyduje, czy ktoś jeszcze czeka na wynik, oraz odsetek przebiegów, w których coś padło bez zmiany w kodzie, bo on decyduje, czy ten wynik cokolwiek znaczy. Liczba jobów nie jest metryką, jest kosztem.
U mnie zeszło z czterdziestu minut do jedenastu, przy czterech jobach i równoległych klasach wewnątrz każdego. Największy zysk czasowy dał podział na joby, ale największy zysk w zaufaniu do zestawu dała nudna robota z sekcji o izolacji: wyrzucenie statycznego drivera i rozdzielenie danych. To po niej przestałem oglądać czerwone testy, które po ponownym uruchomieniu były zielone.
Zostaje mi jedna rzecz do poukładania i widać ją już w tym wpisie: dane. Prefiksy, sprzątanie po sobie i osobne konta na plasterek to obejścia tego, że nie mam spisanej strategii danych testowych, a przy równoległości przestaje to być kwestia higieny. Chcę to opisać osobno. Całość i tak wraca do pytania, które zadaję sobie za każdym razem: gdzie to się mieści w całym procesie testowym. Szybszy pipeline nie naprawia procesu. Sprawia tylko, że wcześniej widać, co w nim nie działa.

