02Enginyeria5 min

El test que deia verd i mentia

Sis SCRIPT ERROR dins d'un test que imprimia TOTS ELS TESTS OK i sortia amb codi 0. Com es descobreix que un verd és fals i què faig ara abans de fiar-me'n.

Per Salva Castells

Imatge de portada: Roland Tanglao, CC BY 2.0.
En aquest post

Un test d'un joc multijugador en Godot imprimia "TODOS LOS TESTS OK" i sortia amb codi 0. Dins d'aquella mateixa tirada hi havia sis SCRIPT ERROR. Dos deien Compilation failed i un altre Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'. El runner mirava l'exit code, l'exit code era 0, i ningú no mirava res més.

Un test que no pot fallar és pitjor que no tenir test. Sense test saps que no saps. Amb un verd fals creus que saps.

Sis errors dins d'un verd

El bloc que fallava instanciava l'escena completa del nivell per comptar els objectes recollibles que declara. Aquella escena arrossega dues zones d'UI els scripts de les quals fan servir autoloads que, arrencant el motor amb --script, no existeixen. Els scripts no compilaven, el node sortia com a CanvasLayer en comptes del tipus esperat, i Godot escopia els errors per stderr sense tombar el procés.

La part lletja no és l'error, és que el test continuava endavant i les asseveracions posteriors s'avaluaven sobre un arbre a mig construir.

L'arreglament va ser deixar d'instanciar. L'escena es llegeix amb PackedScene.get_state(), que dona els noms de node, l'instance= i les propietats sobreescrites sense executar cap _ready ni carregar els scripts de les zones. La precondició continua ancorada al fitxer de producció: si algú treu un objecte del nivell, el test es posa vermell.

L'asseveració que era una tautologia

Aquell mateix test tenia un altre problema pitjor, perquè no fa soroll a stderr. La precondició deia "el nivell porta N plantilles" amb N derivat de la mateixa escena, i després comprovava que la conversió tornava N. Això sempre passa. No comprova que converteixi les plantilles que hi ha, comprova que el número que acabes de llegir és igual a si mateix.

Ara aquell número està escrit a mà:

const PLAYGROUND_TEMPLATE_COUNT := 7  # els 7 pickups col·locats a l'editor

I cada payload convertit es casa un a un amb la seva declaració del .tscn per posició, amb tolerància d'1 px perquè el host ja va integrar un frame de gravetat abans de convertir-les, més el seu tipus i la seva quantitat. Fixar el 7 a mà és el que distingeix "converteix totes les que hi hagi" de "converteix les 7 que hi ha".

Com es comprova que un vermell és possible

La manera de saber si un test serveix és trencar el codi expressament i mirar si se n'assabenta. En aquell arreglament vaig deixar escrites les tres mutacions que vaig provar: treure el remove_child de la funció que desenganxa les plantilles donava vermell en tres asseveracions; enviar la quantitat a 1 al payload donava vermell en dues; esborrar un objecte de l'escena donava vermell a la precondició.

Trenta segons per mutació, i és l'única evidència real que un test protegeix alguna cosa. Un test que mai no has vist en vermell és una hipòtesi.

La cursa que feia passar el test de dues instàncies

Un script aixecava dues instàncies del joc, host i client per ENet a localhost, i capturava l'estat de totes dues. Sortia verd. Tenia tres verds falsos alhora, i els tres els vaig reproduir abans de tocar res:

  • El host es moria a mitges i donava 0. El client ja havia capturat els seus frames amb jugador local, així que l'script es donava per satisfet. Ara s'exigeix a més que el host mori pel SIGTERM del mateix script; el codi mesurat és 143.
  • El pkill -f ficava rutes sense escapar en una ERE. Amb un + a la ruta no casava i el host vivia fins al timeout: tres minuts en comptes de vint segons. Amb un . podia matar el Godot d'una altra tirada. Ara es mata per PID del fill directe.
  • El client endevinava per número de frame quan existia el host. I no es pot endevinar, perquè abans d'hostatjar Godot posa un OfflineMultiplayerPeer: get_unique_id() torna 1 i is_server() torna true al menú, també a la instància que serà client. L'únic marcador real de "està hostatjant" és que el seu JSON tingui un jugador amb is_local.

De passada va sortir que els at de l'autopilot compten frames dibuixats, no segons: amb vsync, una finestra tapada o sense focus espera el compositor i cau a 1-3 fps. Mesurat, 85 frames en 90 segons. Amb --disable-vsync --max-fps 60, 51 fps estables.

Provar l'artefacte que distribueixes, no el que compiles

El cas que més em va costar va ser en una app d'escriptori de transcripció. El CI era verd i publicava un .deb amb el micròfon mort, perquè res no executava mai el que construïa.

PyInstaller empaquetava el PortAudio del wheel de sounddevice, compilat sense backend de PulseAudio, més un libasound.so.2 sense els seus mòduls d'alsa-lib. Dins del bundle això esborra els PCM pulse, pipewire i default i només deixa els hw: en accés exclusiu. PipeWire ja té el micro agafat, així que gravar fallava amb Invalid sample rate [PaErrorCode -9997].

Només es reproduïa al .deb instal·lat: en desenvolupament s'enllaça contra el PortAudio del sistema, que sí que té Pulse. L'arreglament va ser excloure libportaudio, libasound i libjack del bundle, que ja venien exigits a Depends.

El que va canviar al CI importa més que l'arreglament. Ara, abans d'empaquetar, comprova que no hi ha llibreries d'àudio dins del bundle i que el binari arrenca sota xvfb. I el .deb declarava libc6 (>= 2.43), un terra que no s'instal·la en cap Ubuntu: sortia d'agafar el màxim tag de GLIBC del bundle, és a dir, de llibreries copiades de la màquina que compila. Descrivia el constructor, no l'app. Ara està declarat a mà com el que és, una decisió de release: 2.35, la de la runner.

També vaig treure el build de Windows del CI: el vaig comentar sencer i vaig treure build-windows dels needs del release. Mantenir verd un artefacte que no provava ningú costava més del que donava.

El que em pregunto ara davant d'un test nou

Dues coses, i amb que en falli una ja no me'n fio. La primera: l'he vist en vermell per un canvi que trenca el comportament de debò, no per un que en trenca la forma? La segona: el valor esperat surt d'un altre lloc que el codi jutjat, o l'estic derivant d'ell?

I una tercera per al CI: el que proves ha de ser el fitxer que es descarregarà algú, no el que surt del teu run.