Tests y benchmarks
Declarar tests como nodos del grafo, los modos de meson test que casi nadie usa, los protocolos y códigos de salida que dan granularidad, y la integración con sanitizers junto al error que hace que pasen sin detectar nada.
En C, una suite de tests que pasa no significa que el programa sea correcto: significa que ninguna de las rutas ejercitadas produjo un fallo observable. Y en un lenguaje donde escribir un byte más allá de un búfer suele no producir nada observable, esa distinción lo es todo. Por eso la parte interesante de los tests en Meson no es cómo se declaran, que es trivial, sino cómo se ejecutan: bajo qué instrumentación, con qué códigos de salida y con qué garantía de que un error detectado se convierte de verdad en un test rojo.
- Declarar tests y benchmarks como targets con suites, entorno, tiempo límite y dependencias.
- Manejar los modos de
meson testque sirven para diagnosticar, no solo para pasar. - Usar protocolos y códigos de salida para distinguir fallo, omisión y error irrecuperable.
- Integrar sanitizers de forma que un error detectado haga fallar el test, que no es lo que ocurre por defecto.
Declarar tests y benchmarks
Un test en Meson es un nodo del grafo: un ejecutable que se construye como cualquier otro y una declaración de cómo invocarlo.
prueba_vector = executable('prueba_vector', 'tests/vector.c',
dependencies: mates_dep,
build_by_default: false)
test('vector basico', prueba_vector,
suite: 'unidad',
timeout: 30)
test('vector con datos grandes', prueba_vector,
args: ['--tamano', '10000000'],
suite: ['unidad', 'lento'],
timeout: 300,
env: {'MATES_SEMILLA': '42'})
test('rechaza tamano negativo', prueba_vector,
args: ['--tamano', '-1'],
should_fail: true)
build_by_default: false evita que los binarios de prueba se construyan en una compilación normal; Meson los construye igualmente antes de ejecutar los tests porque conoce la dependencia. Las suites son etiquetas arbitrarias que después permiten filtrar, y merece la pena diseñarlas desde el principio: separar lo rápido de lo lento es lo que hace que alguien ejecute los tests durante el desarrollo en lugar de solo en integración continua.
should_fail: true invierte el criterio y sirve para comprobar que las validaciones rechazan lo que deben. env fija variables solo para esa invocación, lo que resuelve el problema de las semillas aleatorias sin tocar el código.
Los benchmarks usan la misma firma y difieren en la semántica de ejecución:
benchmark('multiplicacion de matrices', bench_matriz,
args: ['--repeticiones', '50'],
timeout: 0)
Un benchmark no se ejecuta con meson test normal sino con --benchmark, se ejecuta en serie y sin tiempo límite por defecto. Las tres diferencias responden al mismo motivo: medir tiempo mientras otras tareas compiten por los mismos núcleos y las mismas cachés produce números sin significado. Meson no mide por ti ni interpreta la salida; lo que aporta es la garantía de aislamiento.
Los modos de meson test
La orden básica se conoce enseguida; lo que distingue a quien sabe usarla son los modos de diagnóstico.
meson test -C build # todo, en paralelo
meson test -C build --list # que tests existen
meson test -C build --suite unidad # solo una suite
meson test -C build --no-suite lento # todo menos la lenta
meson test -C build --print-errorlogs # imprime la salida de los que fallan
meson test -C build 'vector basico' -v # uno concreto, con su salida
meson test -C build --repeat 100 # cien veces: caza fallos intermitentes
meson test -C build --num-processes 1 # en serie: descarta interferencia
meson test -C build --gdb 'vector basico' # lanza el test bajo el depurador
meson test -C build -t 10 # multiplica todos los tiempos limite
repeat
Un test que falla una vez de cada treinta no es ruido: es una condición de carrera o memoria sin inicializar.
num-processes 1
Si el fallo desaparece en serie, el problema está en recursos compartidos entre tests, no en el código.
gdb
Arranca el binario bajo el depurador con el mismo entorno, argumentos y directorio de trabajo que en la suite.
meson-logs
Cada ejecución deja testlog.txt y testlog.json con salida completa, tiempos y códigos de salida.
La combinación --repeat con --num-processes 1 es el instrumento estándar para clasificar un fallo intermitente antes de perseguirlo: si repitiendo en serie desaparece, el problema es de aislamiento entre tests; si persiste, es del código.
Los conjuntos de ejecución permiten declarar de una vez una forma completa de correr la suite:
add_test_setup('valgrind',
exe_wrapper: ['valgrind', '--leak-check=full', '--error-exitcode=1'],
timeout_multiplier: 20)
add_test_setup('estricto',
env: {'UBSAN_OPTIONS': 'halt_on_error=1:print_stacktrace=1'},
timeout_multiplier: 5,
is_default: false)
meson test -C build --setup=valgrind
El --error-exitcode=1 de Valgrind no es opcional: sin él, Valgrind informa de los errores en su salida y termina con el código del programa, así que un test con fugas de memoria pasa en verde. Es exactamente el mismo problema que veremos con UBSan, y aparece siempre por la misma razón.
Protocolos y códigos de salida
Por defecto Meson interpreta el código de salida: cero es éxito y cualquier otra cosa es fallo. Sobre esa base añade dos valores con significado especial, heredados de la tradición de Automake y hoy convención de facto.
flowchart TB
A[El binario de test termina] --> B{Codigo de salida}
B -->|0| C[Correcto]
B -->|77| D[Omitido: falta un requisito del entorno]
B -->|99| E[Error grave: el test no llego a evaluar nada]
B -->|otro| F[Fallo]
B -->|senal o tiempo agotado| G[Fallo con causa registrada en el log]La diferencia entre 77 y 99 tiene consecuencias reales. Un test que necesita una tarjeta de red, un dispositivo concreto o un permiso que no tiene debe devolver 77: no ha fallado, no era aplicable, y contarlo como fallo entrena al equipo a ignorar el color rojo. Un test cuyo entorno está tan roto que ni siquiera pudo empezar devuelve 99, y eso sí es una alarma sobre la infraestructura, no sobre el código.
Cuando el código de salida se queda corto, se cambia de protocolo:
test('parser completo', prueba_parser, protocol: 'tap')
TAP, el Test Anything Protocol, es un formato de texto de una simplicidad casi ofensiva: el programa imprime una línea por comprobación indicando si pasó, y Meson las contabiliza por separado. La ganancia es de granularidad: con el protocolo de código de salida, un binario con doscientas comprobaciones es un test que pasa o falla; con TAP son doscientos resultados, y sabes cuál de ellos se rompió sin leer la salida. Meson entiende además los protocolos nativos de GoogleTest y de Rust.
Sanitizers: hacer que un fallo sea un fallo
Aquí converge todo. Los sanitizers son instrumentación que el compilador inserta para detectar en ejecución lo que el lenguaje no comprueba, y Meson los expone como una opción de build, sin tocar una línea del proyecto.
meson setup build-asan -Db_sanitize=address,undefined -Db_lundef=false
meson compile -C build-asan
meson test -C build-asan -t 10
El patrón profesional es mantener varios directorios de build en paralelo y ejecutar la misma suite en todos: uno normal para el ciclo rápido, uno con dirección y comportamiento indefinido, uno con el sanitizer de hilos si hay concurrencia. Cada directorio guarda su configuración, así que cambiar de uno a otro no reconfigura nada. El -t 10 no es cosmético: la instrumentación multiplica los tiempos de ejecución por un factor que va de dos a veinte, y sin él la mitad de la suite falla por tiempo agotado en lugar de por errores reales.
Y ahora el error que casi todo el mundo comete la primera vez. UndefinedBehaviorSanitizer, por defecto, no aborta. Detecta el desbordamiento de un entero con signo, imprime un diagnóstico impecable en la salida de error, continúa la ejecución y el programa termina con código cero. Meson ve un cero, marca el test en verde y tú has construido una suite que detecta problemas y los oculta.
add_test_setup('sanitizers',
env: {
'UBSAN_OPTIONS': 'halt_on_error=1:print_stacktrace=1',
'ASAN_OPTIONS': 'detect_leaks=1:abort_on_error=1',
'LSAN_OPTIONS': 'suppressions=tests/fugas_conocidas.txt',
},
timeout_multiplier: 20)
La alternativa que no depende del entorno es pedírselo al compilador con -fno-sanitize-recover=undefined en los c_args del proyecto, lo que hace que abortar sea el comportamiento compilado y no una variable que alguien puede olvidar exportar en el servidor de integración. Conviene además saber qué combinaciones son imposibles: el sanitizer de hilos no convive con el de dirección, y ninguno de los dos con Valgrind, porque los tres reescriben la gestión de memoria de formas incompatibles.
Hay una asimetría en los tests que se enseña poco y que en C resulta brutal: un test rojo transmite información, un test verde apenas transmite ninguna. El rojo dice hay un fallo aquí, con evidencia. El verde dice esta ejecución concreta no produjo un síntoma que yo supiera reconocer, y esa frase es mucho más débil de lo que parece cuando el lenguaje permite que leer memoria liberada devuelva el valor correcto durante meses, que un desbordamiento de búfer pise una variable que nadie vuelve a mirar, o que un entero con signo desbordado produzca justo el resultado que esperabas en la máquina donde lo probaste. La consecuencia práctica es que en C invertir en más casos de prueba tiene rendimientos decrecientes mucho antes que invertir en mejores detectores: la misma suite corriendo bajo ASan y UBSan encuentra clases enteras de errores que mil casos nuevos sin instrumentación no encontrarían jamás, porque el problema nunca fue la cobertura de entradas sino la observabilidad de los síntomas. De ahí que el detalle aparentemente burocrático de halt_on_error sea en realidad el corazón del asunto: un detector que informa pero no rompe el build es peor que no tener detector, porque produce la ilusión de vigilancia mientras entrena al equipo a ignorar la salida. Cuando montes cualquier sistema de calidad —tests, linters, análisis estático, comprobaciones de tipos— la pregunta que decide si servirá de algo no es qué detecta, sino qué ocurre exactamente cuando detecta algo. Si la respuesta es una línea más en un log que nadie lee, acabas de construir teatro.
- Declara tres tests con suites
unidadylento, y comprueba que--suitey--no-suitefiltran como esperas. - Escribe un test que devuelva 77 cuando falte un requisito del entorno y verifica que Meson lo cuenta como omitido, no como fallo.
- Introduce a propósito un desbordamiento de entero con signo. Compila con
-Db_sanitize=undefined, ejecuta la suite y confirma que pasa en verde pese al diagnóstico impreso. - Arregla el punto anterior de las dos formas: con
UBSAN_OPTIONSen unadd_test_setupy con-fno-sanitize-recover=undefineden losc_args. Argumenta cuál prefieres para integración continua. - Añade un benchmark, ejecútalo con
--benchmarky explica por qué Meson se niega a ejecutarlo en paralelo.