Un test di un gioco multiplayer in Godot stampava "TUTTI I TEST OK" e usciva con codice 0. Dentro quella stessa esecuzione c'erano sei SCRIPT ERROR. Due dicevano Compilation failed e un altro Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'. Il runner guardava l'exit code, l'exit code era 0, e nessuno guardava oltre.
Un test che non può fallire è peggio che non avere test. Senza test sai di non sapere. Con un verde falso credi di sapere.
Sei errori dentro un verde
Il blocco che falliva istanziava la scena completa del livello per contare gli oggetti raccoglibili che dichiara. Quella scena si trascina dietro due zone di UI i cui script usano autoload che, avviando il motore con --script, non esistono. Gli script non compilavano, il nodo usciva come CanvasLayer invece che come il tipo atteso, e Godot sputava gli errori su stderr senza buttare giù il processo.
La parte brutta non è l'errore, è che il test tirava dritto e le asserzioni successive venivano valutate su un albero costruito a metà.
La correzione è stata smettere di istanziare. La scena si legge con PackedScene.get_state(), che dà i nomi dei nodi, l'instance= e le proprietà sovrascritte senza eseguire nessun _ready né caricare gli script delle zone. La precondizione resta ancorata al file di produzione: se qualcuno toglie un oggetto dal livello, il test diventa rosso.
L'asserzione che era una tautologia
Quello stesso test aveva un altro problema, peggiore, perché non fa rumore su stderr. La precondizione diceva "il livello porta N template" con N derivato dalla scena stessa, e poi verificava che la conversione restituisse N. Questo passa sempre. Non verifica che converta i template che ci sono, verifica che il numero che hai appena letto sia uguale a sé stesso.
Ora quel numero è scritto a mano:
const PLAYGROUND_TEMPLATE_COUNT := 7 # i 7 pickup piazzati nell'editor
E ogni payload convertito si accoppia uno a uno con la sua dichiarazione nel .tscn per posizione, con una tolleranza di 1 px perché l'host aveva già integrato un frame di gravità prima di convertirle, più il suo tipo e la sua quantità. Fissare il 7 a mano è ciò che distingue "converte tutte quelle che ci sono" da "converte le 7 che ci sono".
Come si verifica che un rosso sia possibile
Il modo per sapere se un test serve è rompere il codice apposta e guardare se se ne accorge. In quella correzione ho lasciato scritte le tre mutazioni che ho provato: togliere il remove_child dalla funzione che stacca i template dava rosso su tre asserzioni; mandare la quantità a 1 nel payload dava rosso su due; cancellare un oggetto dalla scena dava rosso sulla precondizione.
Trenta secondi per mutazione, ed è l'unica prova reale che un test protegga qualcosa. Un test che non hai mai visto rosso è un'ipotesi.
La race condition che faceva passare il test delle due istanze
Uno script avviava due istanze del gioco, host e client via ENet su localhost, e catturava lo stato di entrambe. Usciva verde. Aveva tre verdi falsi in contemporanea, e li ho riprodotti tutti e tre prima di toccare qualsiasi cosa:
- L'host moriva a metà e dava 0. Il client aveva già catturato i suoi frame con il giocatore locale, quindi lo script si dichiarava soddisfatto. Ora si pretende anche che l'host muoia per il
SIGTERMdello script stesso; il codice misurato è 143. - Il
pkill -fmetteva percorsi non escapati in una ERE. Con un+nel percorso non combaciava e l'host viveva fino al timeout: tre minuti invece di venti secondi. Con un.poteva ammazzare il Godot di un'altra esecuzione. Ora si uccide per PID del figlio diretto. - Il client indovinava dal numero di frame quando esistesse l'host. E non si può indovinare, perché prima di fare da host Godot mette un
OfflineMultiplayerPeer:get_unique_id()restituisce 1 eis_server()restituisce true nel menu, anche nell'istanza che farà da client. L'unico marcatore reale di "sta facendo da host" è che il suo JSON abbia un giocatore conis_local.
Già che c'ero è saltato fuori che gli at dell'autopilot contano frame disegnati, non secondi: con il vsync, una finestra coperta o senza focus aspetta il compositor e scende a 1-3 fps. Misurato, 85 frame in 90 secondi. Con --disable-vsync --max-fps 60, 51 fps stabili.
Provare l'artefatto che distribuisci, non quello che compili
Il caso che mi è costato di più è stato in un'app desktop di trascrizione. La CI era verde e pubblicava un .deb con il microfono morto, perché nessuno eseguiva mai quello che costruiva.
PyInstaller impacchettava il PortAudio del wheel di sounddevice, compilato senza backend PulseAudio, più una libasound.so.2 senza i suoi moduli di alsa-lib. Dentro il bundle questo cancella i PCM pulse, pipewire e default e lascia solo gli hw: in accesso esclusivo. PipeWire ha già preso il microfono, quindi registrare falliva con Invalid sample rate [PaErrorCode -9997].
Si riproduceva solo nel .deb installato: in sviluppo ci si collega al PortAudio di sistema, che Pulse ce l'ha. La correzione è stata escludere libportaudio, libasound e libjack dal bundle, che erano già richieste nelle Depends.
Quello che è cambiato nella CI conta più della correzione. Ora, prima di impacchettare, verifica che non ci siano librerie audio dentro il bundle e che il binario parta sotto xvfb. E il .deb dichiarava libc6 (>= 2.43), una soglia che non si installa su nessuna Ubuntu: veniva dal prendere il tag GLIBC massimo del bundle, cioè da librerie copiate dalla macchina che compila. Descriveva il costruttore, non l'app. Ora è dichiarata a mano per quello che è, una decisione di release: 2.35, quella del runner.
Ho anche tolto la build di Windows dalla CI: l'ho commentata per intero e ho tolto build-windows dai needs della release. Tenere verde un artefatto che nessuno provava costava più di quanto rendesse.
Cosa mi chiedo oggi davanti a un test nuovo
Due cose, e basta che ne fallisca una perché non mi fidi più. La prima: l'ho visto rosso per un cambiamento che rompe il comportamento davvero, non per uno che rompe la forma? La seconda: il valore atteso viene da un posto diverso dal codice sotto giudizio, o lo sto derivando da lui?
E una terza per la CI: quello che provi deve essere il file che qualcuno scaricherà, non quello che esce dal tuo run.
