02Inżynieria5 min

Test, który świecił na zielono i kłamał

Sześć SCRIPT ERROR wewnątrz testu, który wypisywał WSZYSTKIE TESTY OK i kończył z kodem 0. Jak rozpoznać fałszywą zieleń i co robię teraz, zanim jakiejś zaufam.

Autor: Salva Castells

Zdjęcie okładkowe: Roland Tanglao, CC BY 2.0.
W tym wpisie

Test gry wieloosobowej w Godocie wypisywał "TODOS LOS TESTS OK" i kończył z kodem 0. W tym samym przebiegu było sześć SCRIPT ERROR. Dwa z nich mówiły Compilation failed, a inny Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'. Runner patrzył na exit code, exit code wynosił 0, i nikt nie patrzył dalej.

Test, który nie może się wywalić, jest gorszy niż brak testu. Bez testu wiesz, że nie wiesz. Przy fałszywej zieleni myślisz, że wiesz.

Sześć błędów wewnątrz zieleni

Blok, który zawodził, instancjonował całą scenę poziomu, żeby policzyć zadeklarowane w niej znajdźki. Ta scena ciągnie dwie strefy UI, których skrypty używają autoloadów nieistniejących przy uruchomieniu silnika z --script. Skrypty się nie kompilowały, węzeł wychodził jako CanvasLayer zamiast oczekiwanego typu, a Godot wypluwał błędy na stderr, nie kładąc procesu.

Brzydkie nie jest to, że był błąd, tylko to, że test szedł dalej, a kolejne asercje były wyliczane na wpół zbudowanym drzewie.

Naprawą było przestanie instancjonować. Scenę czyta się przez PackedScene.get_state(), co daje nazwy węzłów, instance= i nadpisane właściwości, bez wykonywania żadnego _ready i bez ładowania skryptów stref. Warunek wstępny nadal jest zakotwiczony w pliku produkcyjnym: jeśli ktoś usunie obiekt z poziomu, test robi się czerwony.

Asercja, która była tautologią

Ten sam test miał inny, gorszy problem, bo nie robi hałasu na stderr. Warunek wstępny mówił "poziom niesie N szablonów", z N wyprowadzonym z samej sceny, a potem sprawdzał, czy konwersja zwraca N. To przechodzi zawsze. Nie sprawdza, czy konwertuje te szablony, które są — sprawdza, czy liczba, którą właśnie odczytałeś, równa się sama sobie.

Teraz ta liczba jest wpisana ręcznie:

const PLAYGROUND_TEMPLATE_COUNT := 7  # 7 znajdziek rozstawionych w edytorze

A każdy skonwertowany payload jest zestawiany jeden do jednego ze swoją deklaracją z .tscn po pozycji, z tolerancją 1 px, bo host zdążył już scałkować jedną klatkę grawitacji przed konwersją, plus po typie i ilości. Wpisanie siódemki na sztywno jest tym, co odróżnia "konwertuje wszystkie, jakie są" od "konwertuje te 7, które są".

Jak sprawdzić, że czerwień jest możliwa

Sposobem na sprawdzenie, czy test się do czegoś nadaje, jest celowe zepsucie kodu i sprawdzenie, czy się połapie. Przy tej naprawie zostawiłem spisane trzy mutacje, które przetestowałem: usunięcie remove_child z funkcji odczepiającej szablony dawało czerwień w trzech asercjach; wysłanie ilości równej 1 w payloadzie dawało czerwień w dwóch; skasowanie obiektu ze sceny dawało czerwień w warunku wstępnym.

Trzydzieści sekund na mutację, i to jedyny prawdziwy dowód na to, że test czegoś broni. Test, którego nigdy nie widziałeś na czerwono, jest hipotezą.

Wyścig, przez który test z dwiema instancjami przechodził

Skrypt podnosił dwie instancje gry, hosta i klienta przez ENet na localhoście, i zbierał stan obu. Wychodziło na zielono. Miał trzy fałszywe zielenie naraz, i wszystkie trzy odtworzyłem, zanim czegokolwiek tknąłem:

  • Host umierał w połowie i zwracał 0. Klient zdążył już zebrać swoje klatki z lokalnym graczem, więc skrypt uznawał się za usatysfakcjonowanego. Teraz wymaga się dodatkowo, żeby host umarł od SIGTERM wysłanego przez sam skrypt; mierzony kod to 143.
  • pkill -f wrzucał nieescapowane ścieżki do ERE. Przy + w ścieżce nie dopasowywało i host żył do timeoutu: trzy minuty zamiast dwudziestu sekund. Przy . mógł zabić Godota z innego przebiegu. Teraz zabija się po PID-zie bezpośredniego dziecka.
  • Klient zgadywał po numerze klatki, kiedy host już istnieje. A zgadywać się nie da, bo przed hostowaniem Godot ustawia OfflineMultiplayerPeer: get_unique_id() zwraca 1, a is_server() zwraca true w menu, także w instancji, która ma zostać klientem. Jedynym prawdziwym znacznikiem "hostuje" jest to, że jego JSON ma gracza z is_local.

Przy okazji wyszło, że at w autopilocie liczy narysowane klatki, nie sekundy: przy vsyncu zasłonięte albo nieaktywne okno czeka na kompozytor i spada do 1-3 fps. Zmierzone: 85 klatek w 90 sekund. Przy --disable-vsync --max-fps 60 — stabilne 51 fps.

Testuj artefakt, który dystrybuujesz, nie ten, który kompilujesz

Najtrudniejszy przypadek miałem w desktopowej aplikacji do transkrypcji. CI świeciło na zielono i publikowało .deb z martwym mikrofonem, bo nic nigdy nie uruchamiało tego, co budowało.

PyInstaller pakował PortAudio z wheela sounddevice, skompilowane bez backendu PulseAudio, plus libasound.so.2 bez jego modułów z alsa-lib. Wewnątrz bundle'a to kasuje PCM-y pulse, pipewire i default, zostawiając tylko te hw: w dostępie wyłącznym. PipeWire trzyma już mikrofon, więc nagrywanie padało z Invalid sample rate [PaErrorCode -9997].

Odtwarzało się to tylko w zainstalowanym .deb: w developmencie linkuje się systemowe PortAudio, które Pulse ma. Naprawą było wykluczenie libportaudio, libasound i libjack z bundle'a — i tak były już wymagane w Depends.

To, co zmieniło się w CI, jest ważniejsze niż sama naprawa. Teraz, przed spakowaniem, sprawdza się, że w bundlu nie ma bibliotek audio i że binarka startuje pod xvfb. A .deb deklarował libc6 (>= 2.43), podłogę, która nie instaluje się na żadnej Ubuntu: brała się z wzięcia maksymalnego taga GLIBC z bundle'a, czyli z bibliotek skopiowanych z maszyny, która kompiluje. Opisywała buildera, nie aplikację. Teraz jest zadeklarowana ręcznie jako to, czym jest — decyzja wydaniowa: 2.35, ta z runnera.

Wyrzuciłem też build Windowsa z CI: zakomentowałem go w całości i usunąłem build-windows z needs release'u. Utrzymywanie na zielono artefaktu, którego nikt nie testował, kosztowało więcej, niż dawało.

O co się teraz pytam przy nowym teście

O dwie rzeczy, i wystarczy, że jedna nie przejdzie, a już nie ufam. Pierwsza: czy widziałem go na czerwono przy zmianie psującej naprawdę zachowanie, a nie tylko formę? Druga: czy wartość oczekiwana bierze się z innego miejsca niż oceniany kod, czy wyprowadzam ją z niego?

I trzecia, dla CI: to, co testujesz, musi być plikiem, który ktoś pobierze, a nie tym, który wychodzi z twojego run.