Ein Test eines Multiplayer-Spiels in Godot gab "ALLE TESTS OK" aus und endete mit Code 0. Innerhalb desselben Durchlaufs gab es sechs SCRIPT ERROR. Zwei davon sagten Compilation failed und ein weiterer Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'. Der Runner schaute auf den Exit Code, der Exit Code war 0, und weiter schaute niemand.
Ein Test, der nicht fehlschlagen kann, ist schlimmer als kein Test. Ohne Test weißt du, dass du nichts weißt. Mit einem falschen Grün glaubst du, dass du es weißt.
Sechs Fehler in einem Grün
Der fehlschlagende Block instanziierte die komplette Szene des Levels, um die einsammelbaren Objekte zu zählen, die es deklariert. Diese Szene schleppt zwei UI-Bereiche mit, deren Skripte Autoloads verwenden, die es nicht gibt, wenn man die Engine mit --script startet. Die Skripte kompilierten nicht, der Node kam als CanvasLayer statt als erwarteter Typ heraus, und Godot spuckte die Fehler über stderr aus, ohne den Prozess umzuwerfen.
Der hässliche Teil ist nicht der Fehler, sondern dass der Test weiterlief und die nachfolgenden Assertions auf einem halb aufgebauten Baum ausgewertet wurden.
Die Korrektur bestand darin, aufzuhören zu instanziieren. Die Szene wird mit PackedScene.get_state() gelesen, was die Node-Namen, das instance= und die überschriebenen Eigenschaften liefert, ohne irgendein _ready auszuführen oder die Skripte der Bereiche zu laden. Die Vorbedingung bleibt an der Produktionsdatei verankert: Wenn jemand ein Objekt aus dem Level entfernt, wird der Test rot.
Die Assertion, die eine Tautologie war
Derselbe Test hatte ein weiteres, schlimmeres Problem, denn es macht keinen Lärm auf stderr. Die Vorbedingung sagte "das Level bringt N Vorlagen mit", wobei N aus der Szene selbst abgeleitet war, und prüfte dann, dass die Konvertierung N zurückgab. Das trifft immer zu. Es prüft nicht, dass die vorhandenen Vorlagen konvertiert werden, es prüft, dass die Zahl, die du gerade gelesen hast, gleich sich selbst ist.
Jetzt steht diese Zahl von Hand geschrieben da:
const PLAYGROUND_TEMPLATE_COUNT := 7 # die 7 im Editor platzierten Pickups
Und jedes konvertierte Payload wird eins zu eins mit seiner Deklaration in der .tscn über die Position abgeglichen, mit einer Toleranz von 1 px, weil der Host vor der Konvertierung bereits ein Frame Schwerkraft integriert hat, dazu sein Typ und seine Menge. Die 7 von Hand festzusetzen ist das, was "konvertiert alle, die es gibt" von "konvertiert die 7, die es gibt" unterscheidet.
Wie man prüft, dass ein Rot möglich ist
Der Weg herauszufinden, ob ein Test taugt, ist, den Code absichtlich kaputtzumachen und zu schauen, ob er es merkt. Bei dieser Korrektur habe ich die drei Mutationen aufgeschrieben, die ich probiert habe: das remove_child aus der Funktion zu entfernen, die die Vorlagen ablöst, gab Rot in drei Assertions; die Menge im Payload auf 1 zu setzen gab Rot in zweien; ein Objekt aus der Szene zu löschen gab Rot in der Vorbedingung.
Dreißig Sekunden pro Mutation, und das ist der einzige echte Beleg dafür, dass ein Test etwas schützt. Ein Test, den du nie rot gesehen hast, ist eine Hypothese.
Das Race, das den Test mit zwei Instanzen bestehen ließ
Ein Skript startete zwei Instanzen des Spiels, Host und Client über ENet auf localhost, und erfasste den Zustand beider. Es kam grün heraus. Es hatte drei falsche Grüns gleichzeitig, und alle drei habe ich reproduziert, bevor ich irgendetwas angefasst habe:
- Der Host starb auf halbem Weg und lieferte 0. Der Client hatte seine Frames mit lokalem Spieler schon erfasst, also gab sich das Skript zufrieden. Jetzt wird zusätzlich verlangt, dass der Host am
SIGTERMdes Skripts selbst stirbt; der gemessene Code ist 143. - Das
pkill -fsteckte unescapte Pfade in eine ERE. Mit einem+im Pfad passte es nicht und der Host lebte bis zum Timeout: drei Minuten statt zwanzig Sekunden. Mit einem.konnte es das Godot eines anderen Durchlaufs abschießen. Jetzt wird über die PID des direkten Kindprozesses gekillt. - Der Client riet anhand der Frame-Nummer, wann der Host existierte. Und raten geht nicht, denn vor dem Hosten setzt Godot einen
OfflineMultiplayerPeer:get_unique_id()liefert 1 undis_server()liefert true im Menü, auch in der Instanz, die Client werden soll. Der einzige echte Marker für "hostet" ist, dass sein JSON einen Spieler mitis_localenthält.
Nebenbei kam heraus, dass die at des Autopiloten gezeichnete Frames zählen, keine Sekunden: Mit VSync wartet ein verdecktes oder unfokussiertes Fenster auf den Compositor und fällt auf 1-3 fps. Gemessen: 85 Frames in 90 Sekunden. Mit --disable-vsync --max-fps 60 stabile 51 fps.
Das Artefakt testen, das du verteilst, nicht das, das du baust
Der Fall, der mich am meisten gekostet hat, war in einer Desktop-App für Transkription. Die CI war grün und veröffentlichte ein .deb mit totem Mikrofon, weil nie etwas ausführte, was gebaut wurde.
PyInstaller paketierte das PortAudio aus dem Wheel von sounddevice, kompiliert ohne PulseAudio-Backend, dazu ein libasound.so.2 ohne seine alsa-lib-Module. Im Bundle löscht das die PCMs pulse, pipewire und default und lässt nur die hw: im exklusiven Zugriff übrig. PipeWire hat das Mikro schon belegt, also schlug die Aufnahme mit Invalid sample rate [PaErrorCode -9997] fehl.
Reproduzieren ließ es sich nur im installierten .deb: In der Entwicklung wird gegen das PortAudio des Systems gelinkt, das Pulse sehr wohl hat. Die Korrektur war, libportaudio, libasound und libjack aus dem Bundle auszuschließen, die ohnehin schon in Depends verlangt wurden.
Was sich in der CI geändert hat, ist wichtiger als die Korrektur. Jetzt wird vor dem Paketieren geprüft, dass keine Audio-Bibliotheken im Bundle liegen und dass das Binary unter xvfb startet. Und das .deb deklarierte libc6 (>= 2.43), eine Untergrenze, die sich auf keinem Ubuntu installieren lässt: Sie kam daher, dass das höchste GLIBC-Tag aus dem Bundle genommen wurde, also aus Bibliotheken, die von der Build-Maschine kopiert wurden. Sie beschrieb den Builder, nicht die App. Jetzt steht sie von Hand als das da, was sie ist, eine Release-Entscheidung: 2.35, die des Runners.
Ich habe außerdem den Windows-Build aus der CI entfernt: komplett auskommentiert und build-windows aus den needs des Release genommen. Ein Artefakt grün zu halten, das niemand testete, kostete mehr, als es brachte.
Was ich mich heute bei einem neuen Test frage
Zwei Dinge, und wenn eines davon durchfällt, traue ich ihm schon nicht mehr. Erstens: Habe ich ihn rot gesehen, wegen einer Änderung, die das Verhalten wirklich kaputtmacht, und nicht wegen einer, die die Form kaputtmacht? Zweitens: Kommt der erwartete Wert aus einer anderen Quelle als dem beurteilten Code, oder leite ich ihn daraus ab?
Und ein drittes für die CI: Was du testest, muss die Datei sein, die jemand herunterladen wird, nicht die, die aus deinem run herausfällt.
