02Techniek5 min

De test die groen zei en loog

Zes SCRIPT ERROR binnen een test die ALLE TESTS OK afdrukte en afsloot met code 0. Hoe je ontdekt dat een groen vals is en wat ik nu doe voordat ik er een vertrouw.

Door Salva Castells

Omslagafbeelding: Roland Tanglao, CC BY 2.0.
In dit bericht

Een test van een multiplayergame in Godot drukte "ALLE TESTS OK" af en sloot af met code 0. Binnen diezelfde run zaten zes SCRIPT ERROR. Twee daarvan zeiden Compilation failed en nog een Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'. De runner keek naar de exit code, de exit code was 0, en niemand keek verder.

Een test die niet kan falen is erger dan geen test. Zonder test weet je dat je het niet weet. Met een vals groen denk je dat je het weet.

Zes fouten binnen een groen

Het blok dat faalde instantieerde de volledige scène van het level om de verzamelbare objecten te tellen die het declareert. Die scène sleept twee UI-zones mee waarvan de scripts autoloads gebruiken die, als je de engine met --script start, niet bestaan. De scripts compileerden niet, de node kwam eruit als CanvasLayer in plaats van het verwachte type, en Godot spuwde de fouten uit over stderr zonder het proces om te trekken.

Het lelijke deel is niet de fout, maar dat de test doorging en de latere asserties werden geëvalueerd op een half opgebouwde boom.

De fix was stoppen met instantiëren. De scène wordt gelezen met PackedScene.get_state(), dat de nodenamen, de instance= en de overschreven properties geeft zonder ook maar één _ready uit te voeren of de scripts van de zones te laden. De preconditie blijft verankerd aan het productiebestand: haalt iemand een object uit het level, dan wordt de test rood.

De assertie die een tautologie was

Diezelfde test had een ander, erger probleem, want het maakt geen herrie op stderr. De preconditie zei "het level bevat N templates" met N afgeleid uit de scène zelf, en controleerde daarna dat de conversie N teruggaf. Dat gaat altijd goed. Het controleert niet dat hij de aanwezige templates converteert, het controleert dat het getal dat je net hebt gelezen gelijk is aan zichzelf.

Nu staat dat getal met de hand geschreven:

const PLAYGROUND_TEMPLATE_COUNT := 7  # de 7 pickups die in de editor zijn geplaatst

En elke geconverteerde payload wordt één op één gekoppeld aan zijn declaratie in de .tscn op positie, met een tolerantie van 1 px omdat de host al een frame zwaartekracht had geïntegreerd voordat ze werden geconverteerd, plus zijn type en zijn aantal. Die 7 met de hand vastzetten is wat "converteert alles wat er is" onderscheidt van "converteert de 7 die er zijn".

Hoe je controleert of een rood mogelijk is

De manier om te weten of een test nut heeft is de code expres breken en kijken of hij het doorheeft. In die fix heb ik de drie mutaties opgeschreven die ik probeerde: de remove_child weghalen uit de functie die de templates losmaakt gaf rood in drie asserties; de hoeveelheid in de payload op 1 zetten gaf rood in twee; een object uit de scène verwijderen gaf rood in de preconditie.

Dertig seconden per mutatie, en het is het enige echte bewijs dat een test iets beschermt. Een test die je nooit rood hebt gezien is een hypothese.

De race die de test met twee instanties liet slagen

Een script startte twee instanties van het spel, host en client via ENet op localhost, en legde de toestand van beide vast. Het kwam groen uit. Het had drie valse groenen tegelijk, en alle drie heb ik gereproduceerd voordat ik iets aanraakte:

  • De host ging halverwege dood en gaf 0. De client had zijn frames met lokale speler al vastgelegd, dus het script was tevreden. Nu wordt bovendien geëist dat de host sterft door de SIGTERM van het script zelf; de gemeten code is 143.
  • De pkill -f stopte niet-geëscapete paden in een ERE. Met een + in het pad matchte hij niet en leefde de host tot de timeout: drie minuten in plaats van twintig seconden. Met een . kon hij de Godot van een andere run doden. Nu wordt er gedood op de PID van het directe kindproces.
  • De client raadde aan de hand van het framenummer wanneer de host bestond. En raden kan niet, want vóór het hosten zet Godot een OfflineMultiplayerPeer: get_unique_id() geeft 1 en is_server() geeft true in het menu, ook in de instantie die client gaat worden. De enige echte marker van "is aan het hosten" is dat zijn JSON een speler met is_local bevat.

En passant bleek dat de at van de autopilot getekende frames telt, geen seconden: met vsync wacht een venster dat bedekt is of geen focus heeft op de compositor en zakt naar 1-3 fps. Gemeten: 85 frames in 90 seconden. Met --disable-vsync --max-fps 60, 51 stabiele fps.

Test het artefact dat je distribueert, niet dat wat je compileert

Het geval dat me het meest kostte was in een desktopapp voor transcriptie. De CI stond groen en publiceerde een .deb met een dode microfoon, want niets voerde ooit uit wat het bouwde.

PyInstaller verpakte de PortAudio uit de wheel van sounddevice, gecompileerd zonder PulseAudio-backend, plus een libasound.so.2 zonder zijn alsa-lib-modules. Binnen de bundle wist dat de PCM's pulse, pipewire en default en bleven alleen de hw: in exclusieve toegang over. PipeWire heeft de microfoon al te pakken, dus opnemen faalde met Invalid sample rate [PaErrorCode -9997].

Het was alleen reproduceerbaar in de geïnstalleerde .deb: in ontwikkeling wordt er gelinkt tegen de PortAudio van het systeem, die wél Pulse heeft. De fix was libportaudio, libasound en libjack uit de bundle uitsluiten, want die werden al geëist in Depends.

Wat er in de CI veranderde is belangrijker dan de fix. Nu wordt er vóór het inpakken gecontroleerd dat er geen audiobibliotheken in de bundle zitten en dat het binary start onder xvfb. En de .deb declareerde libc6 (>= 2.43), een ondergrens die op geen enkele Ubuntu installeert: die kwam uit het nemen van de hoogste GLIBC-tag in de bundle, dus uit bibliotheken die van de buildmachine waren gekopieerd. Hij beschreef de bouwer, niet de app. Nu staat hij met de hand gedeclareerd als wat hij is, een releasebeslissing: 2.35, die van de runner.

Ik heb ook de Windows-build uit de CI gehaald: ik heb hem helemaal uitgecommentarieerd en build-windows uit de needs van de release gehaald. Een artefact groen houden dat niemand testte kostte meer dan het opleverde.

Wat ik me nu afvraag bij een nieuwe test

Twee dingen, en als er één faalt vertrouw ik hem al niet meer. Het eerste: heb ik hem rood gezien door een wijziging die het gedrag echt breekt, niet door een die de vorm breekt? Het tweede: komt de verwachte waarde ergens anders vandaan dan uit de code die beoordeeld wordt, of leid ik hem eruit af?

En een derde voor de CI: wat je test moet het bestand zijn dat iemand gaat downloaden, niet dat wat uit jouw run komt.