02Ingeniería6 min

Cuatro verdes que me mintieron (y cómo los pillé)

Un test que imprimía TODOS LOS TESTS OK con seis SCRIPT ERROR dentro, otro que se comparaba consigo mismo y un CI que publicaba un .deb sin micrófono. Lo que tienen en común y la costumbre que me ha dejado.

Por Salva Castells

Imagen de portada: Roland Tanglao, CC BY 2.0.
En este post

Lo encontré de rebote, buscando otra cosa en la salida de un test de un juego multijugador en Godot. La última línea decía TODOS LOS TESTS OK y el proceso salía con código 0. Unas cuantas líneas más arriba, si hacías scroll, había seis SCRIPT ERROR. Dos eran Compilation failed. Otro decía, literalmente, Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'.

El runner miraba el exit code. El exit code era 0. Y yo, que llevaba semanas viendo ese verde, tampoco miraba más.

Desde entonces he ido cazando verdes falsos casi por deporte, y los que cuento aquí son los cuatro que más me han enseñado. No son exóticos. Sospecho que la mayoría de suites que conozco tienen al menos uno de cada tipo.

1. El test que no podía fallar porque el error no tumbaba nada

El bloque roto instanciaba la escena completa del nivel para contar los objetos recogibles. Esa escena arrastra dos zonas de UI cuyos scripts tiran de autoloads, y cuando arrancas el motor con --script los autoloads no existen. Así que los scripts no compilaban, el nodo acababa siendo un CanvasLayer pelado en vez del tipo que esperaba el código, y Godot soltaba los errores por stderr sin matar el proceso.

Lo que me molesta de verdad no es el error en sí (los autoloads en modo script son una trampa conocida, me la había comido antes). Me molesta que el test siguiera adelante tan tranquilo y evaluara el resto de aserciones sobre un árbol construido a medias. Algunas pasaban por pura casualidad.

El arreglo fue dejar de instanciar. PackedScene.get_state() te da los nombres de nodo, el instance= y las propiedades sobreescritas sin ejecutar ningún _ready ni cargar los scripts de las zonas. Leo el fichero, no lo ejecuto. Y la precondición sigue atada al .tscn de producción: si alguien quita un objeto del nivel, el test se pone rojo, que es justo lo que quiero.

2. La aserción que se comparaba consigo misma

Este es el que más vergüenza me da, porque no hace ruido en ningún sitio. Estaba en el mismo test.

La precondición decía "el nivel trae N plantillas", con N calculado leyendo la propia escena. Luego se comprobaba que la conversión devolvía N. Si la conversión perdía una plantilla porque la escena también la había perdido, verde. Si alguien borraba medio nivel, verde. Lo único que comprobaba era que un número era igual a sí mismo.

Ahora el número está escrito a mano:

const PLAYGROUND_TEMPLATE_COUNT := 7  # los 7 pickups colocados en el editor

Sé que a mucha gente le chirría esto, lo del número mágico en el test y que cambiarlo es mantenimiento. Y sí, cada vez que alguien añade un pickup hay que tocar esa línea. Ese es el punto: que alguien tenga que decidir conscientemente que ahora son 8. "Convierte todas las que haya" y "convierte las 7 que hay" son afirmaciones distintas, y solo la segunda puede fallar.

Además, cada payload convertido se empareja uno a uno con su declaración en el .tscn por posición (con 1 px de tolerancia, porque el host ya ha integrado un frame de gravedad antes de convertirlas), por tipo y por cantidad.

Paréntesis: cómo sé que un test puede ponerse rojo

Rompiendo el código a mano y mirando. Nada de mutation testing con herramienta ni con informe bonito; tres cambios a mano y volver a ejecutar. En ese arreglo dejé apuntadas las mutaciones en la descripción del cambio:

  • quitar el remove_child de la función que despega las plantillas: rojo en tres aserciones
  • poner la cantidad a 1 en el payload: rojo en dos
  • borrar un objeto de la escena: rojo en la precondición

Son treinta segundos por mutación. Y es la única prueba que tengo de que el test protege algo; hasta que no lo he visto rojo, lo trato como una hipótesis.

3. El test de dos instancias que pasaba por una carrera

Un script levantaba dos instancias del juego, host y cliente conectados por ENet en localhost, y capturaba el estado de ambas. Verde. Resulta que tenía tres verdes falsos superpuestos, y me obligué a reproducir los tres antes de arreglar ninguno, porque si no nunca sé cuál de mis cambios ha arreglado qué.

El primero: si el host moría a mitad, el script daba 0 igualmente. El cliente ya había capturado sus frames con jugador local y con eso se daba por satisfecho. Ahora exijo también que el host muera por el SIGTERM que le manda el propio script, y lo compruebo con el código de salida: 143.

El segundo es de los que te hacen dudar de todo el bash que has escrito en tu vida. El pkill -f metía rutas sin escapar dentro de una expresión regular extendida. Con un + en la ruta no casaba, el host sobrevivía hasta el timeout y la tirada tardaba tres minutos en vez de veinte segundos. Con un . podía casar con el Godot de otra tirada y matarlo. Ahora se mata por el PID del hijo directo y ya.

El tercero fue el más interesante. El cliente intentaba adivinar por número de frame cuándo estaba el host listo. No se puede, porque antes de hospedar Godot tiene puesto un OfflineMultiplayerPeer: en el menú get_unique_id() devuelve 1 e is_server() devuelve true, también en la instancia que va a acabar siendo cliente. Si preguntas "¿eres el servidor?" la respuesta es sí para todo el mundo. La única señal fiable de que una instancia está hospedando es que su JSON tenga un jugador con is_local.

Y de propina: los at del autopilot cuentan frames dibujados, no segundos. Con vsync activo, una ventana tapada o sin foco espera al compositor y cae a 1-3 fps. Lo medí: 85 frames en 90 segundos. Con --disable-vsync --max-fps 60 se queda en 51 fps estables. Si tus tests de UI pasan en tu máquina y en la del compañero van a otro ritmo, mira esto antes de subir timeouts.

4. El CI que publicaba un micrófono muerto

Este me costó más que los otros tres juntos. Era una app de escritorio de transcripción, el CI estaba verde, el release salía, y el .deb que se instalaba la gente no grababa nada. Nada en el pipeline ejecutaba nunca lo que construía.

PyInstaller metía en el bundle el PortAudio que viene con el wheel de sounddevice, compilado sin backend de PulseAudio, y además un libasound.so.2 sin los módulos de alsa-lib. Dentro del bundle eso deja la app sin los PCM pulse, pipewire y default, y solo quedan los hw: en acceso exclusivo. Como PipeWire ya tiene el micro cogido, grabar fallaba con Invalid sample rate [PaErrorCode -9997], que es un mensaje estupendo para mandarte a buscar en el sitio equivocado.

En desarrollo no pasaba, claro: ahí se enlaza contra el PortAudio del sistema, que sí trae Pulse. El arreglo fue sacar libportaudio, libasound y libjack del bundle, que de todas formas ya estaban en los Depends del paquete.

Lo que cambié en el CI me parece más importante que el arreglo. Antes de empaquetar ahora se comprueba que no hay librerías de audio dentro del bundle y que el binario arranca bajo xvfb. No es un test end-to-end de grabación (eso en una runner sin micro no sé hacerlo bien, la verdad), pero al menos ejecuta lo que vamos a distribuir.

Tirando de ese hilo salió otra: el .deb declaraba libc6 (>= 2.43), que no se instala en ninguna Ubuntu. Salía de coger el tag de GLIBC más alto del bundle, o sea, de librerías copiadas de la máquina que compila. Esa dependencia describía la runner, no la app. Ahora está puesta a mano, 2.35, la de la runner, y la trato como lo que es: una decisión de release que alguien tiene que tomar.

También quité el build de Windows. Lo comenté entero y saqué build-windows de los needs del release. Me dolió un poco, pero nadie lo probaba y mantenerlo verde era mantener otra mentira.

Lo que me ha quedado

No tengo una teoría general. Tengo dos preguntas que me hago antes de fiarme de un test nuevo, mío o de otro, y con que una falle ya no me lo creo:

  1. ¿Lo he visto en rojo por un cambio que rompe el comportamiento, y no solo la forma?
  2. ¿El valor esperado sale de un sitio distinto al código que estoy juzgando?

Y una para el CI, que es la que más tarde aprendí: lo que pruebas tiene que ser el fichero que se va a descargar alguien, no lo que queda en el directorio después de tu run.

Seguro que hay suites mías ahora mismo con verdes de estos. Lo que sí he cambiado es que un verde que nunca he visto fallar ya no me tranquiliza.