Identificación falsa > Artículos > Una prueba que no puede fallar: comprobar si su suite verde comprueba algo

Una prueba que no puede fallar: comprobar si su suite verde comprueba algo

Tenemos diez pruebas de regresión para el generador de nombres. Las diez estaban en verde.

Eso no demuestra nada. Una suite de pruebas informa de dos cosas — que el código pasa y que la suite sería capaz de darse cuenta si no pasara — y ejecutarla solo le dice la primera. La segunda hay que establecerla por otra vía.

Así que la establecimos de la única manera que funciona de verdad: rompimos el código a propósito, una cosa cada vez, y comprobamos que la prueba correcta se ponía en rojo. Once defectos deliberados. Diez fueron detectados. El undécimo no podía serlo, por una razón estructural que vale más que los diez aciertos juntos.

Y por el camino descubrimos que una de nuestras pruebas había estado, en un momento anterior, en verde sobre código roto — no por descuido en el código sometido a prueba, sino por un error de dos caracteres en el propio fixture de la prueba.

El método

La mecánica no tiene nada de vistoso y se monta en aproximadamente una hora. Para cada defecto:

  1. Copiar todo el árbol de fuentes a un directorio temporal de trabajo.
  2. Aplicar exactamente una mutación a la copia — un fichero, una modificación.
  3. Ejecutar la suite en la copia, limitada a la prueba que se supone que debe detectarla.
  4. Exigir que el estado sea FAIL.
  5. Tirar la copia.

Después, a modo de control, ejecutar la suite una vez sobre una copia sin mutar y confirmar que está entera en verde. Sin ese control no se puede distinguir «la prueba detectó la mutación» de «la prueba llevaba rota en el árbol temporal desde el principio».

Fíjese en lo que exige el paso 3. No «ha fallado alguna prueba» — ha fallado esa prueba concreta. Una mutación que corrompe un fichero de datos suele hacer caer tres pruebas a la vez, y si solo comprueba que el código de salida es distinto de cero, atribuirá la detección a la prueba equivocada y seguirá confiando en una prueba que en realidad está muerta.

Las once mutaciones

Cada una es un defecto plausible, no un cambio de carácter al azar. La distinción importa: mutar < en <= en el límite de un bucle produce defectos que nadie escribiría jamás. Lo que se busca es la forma de error que realmente ha llegado a producción en algún sitio.

#MutaciónFichero objetivoDebe fallar
1Multiplicar por 20 cada peso de población de ciudadlocality/en_US.tsvprueba 5
2Rotar la columna de códigos postales una filalocality/de_DE.tsvprueba 7
3Anteponer un BOM UTF-8 a un fichero de datoslocality/fr_FR.tsvprueba 9
4Convertir el fichero a finales de línea CRLFstreets/it_IT.tsvprueba 9
5Copiar la cohorte de nacimiento de 1941 sobre la de 2001cached/en_US/prueba 10
6Colocar una etiqueta PHP de cierre dentro de un comentario //fichero nuevoprueba 8
7Rotar los desplazamientos de índice de un .bin compilado respecto a su .txtcached/zh_TW/prueba 4
8Eliminar mt_srand($keep) de ng_pick()ng_source.phpprueba 1
9Eliminar mt_srand($keep) de ng_pick_or()ng_source.phpprueba 1
10Eliminar mt_srand($keep) de ng_pick_first()ng_source.phpprueba 1
11XOR entre la única tirada aleatoria del picker y el microsegundo actualng_source.phpprueba 2

Las mutaciones 1 y 5 son los dos defectos en los que más merece la pena detenerse, porque ambos son reales. La mutación 1 es una versión sintética de un defecto que llegamos a entregar en preproducción: un fichero de localidades cuyos pesos eran cifras de área metropolitana en lugar de cifras de ciudad, lo que hacía que sesenta ciudades taiwanesas sumaran el 106,9 % de Taiwán. La mutación 5 es el aspecto que tiene una compilación de cohortes rota — personas de veinte años recibiendo en silencio los nombres de pila de personas de ochenta, con todos los nombres de la salida perfectamente reales.

El resultado: diez de las once mutaciones produjeron el FAIL esperado. Una no lo hizo, y fue la mutación 8.

La prueba que estaba en verde sobre código roto

La prueba 1 comprueba un invariante del que depende todo lo demás. El picker debe dejar el flujo mt_rand en la misma posición exista o no el conjunto de datos que se le ha pedido. Si no lo hace, añadir un solo fichero de datos al árbol cambia todos los campos de todas las fichas generadas, y todos los enlaces permanentes que el sitio haya emitido pasan a resolverse en datos distintos.

La implementación del invariante es un guardar-y-restaurar alrededor de la bifurcación: sacar un número, hacer lo que exija la rama y después mt_srand($keep) para devolver el flujo a su sitio.

La primera versión de la prueba parecía del todo razonable. Sembrar el generador, llamar al picker con un conjunto de datos que existe, anotar el siguiente mt_rand(). Volver a sembrarlo, llamar al picker con un conjunto de datos que no existe y una función de reserva, anotar el siguiente mt_rand(). Exigir que ambos coincidan.

La función de reserva estaba escrita así:

fn() => 'X'

Esa función de reserva no toca nunca mt_rand. Y ahí está todo el defecto — en la prueba, no en el código.

Con una reserva que consume cero tiradas, ambas ramas consumen exactamente una tirada en total. Después el flujo aterriza en el mismo punto exista o no la resiembra. La aserción se cumple por aritmética y no por corrección. La prueba estaba en verde, y habría seguido en verde con mt_srand($keep) borrado por completo del fichero.

La reserva real es una llamada a Faker. Faker consume un número desconocido y variable de tiradas — que es exactamente la condición que la resiembra existe para neutralizar, y exactamente la condición que el fixture había eliminado. El fixture había simplificado por accidente hasta hacer desaparecer lo único que daba sentido a la prueba.

La corrección consiste en hacer la reserva deliberadamente voraz, y voraz en cantidades distintas:

$burn = fn(int $k) => function () use ($k) {
    for ($i = 0; $i < $k; $i++) mt_rand();
    return 'X';
};

mt_srand(4242); ng_pick_first(['t/weighted', 't/flat'], $burn(3));  // dataset present
mt_srand(4242); ng_pick_first(['t/NEMA1', 't/NEMA2'],   $burn(3));  // absent, fallback burns 3
mt_srand(4242); ng_pick_first(['t/NEMA1', 't/NEMA2'],   $burn(10)); // absent, fallback burns 10

Tres llamadas, una sola aserción: el siguiente mt_rand() debe ser idéntico en las tres. La tercera llamada es la que carga con todo el peso. Presente contra ausente podría coincidir por casualidad; ausente-quemando-3 contra ausente-quemando-10 no puede, porque solo una resiembra puede hacer que dos apetitos distintos aterricen en el mismo punto. Las mutaciones 9 y 10 se ponen ambas en rojo frente a este fixture. Frente al antiguo no lo habrían hecho.

La lección general no va de flujos de números aleatorios. Va de esto: un fixture diseñado por comodidad tiende a borrar justo la variación que la prueba existe para detectar, y lo hace en silencio, porque el resultado es una prueba que pasa.

La mutación que no se puede detectar, y por qué la dejamos por escrito

La mutación 8 elimina esa misma resiembra de ng_pick(), y la prueba 1 sigue en verde. Esto no es una laguna que haya que tapar más adelante. Es un límite estructural de la comparación que la prueba realiza.

ng_pick() no tiene reserva. No hay nada a lo que llamar cuando falta el conjunto de datos; la función devuelve null. Así que ambas ramas — conjunto de datos presente, conjunto de datos ausente — consumen exactamente una tirada, y la comparación presente-contra-ausente vuelve a cumplirse esté o no la resiembra. El truco que salvó a ng_pick_or() y a ng_pick_first() no está disponible aquí, porque no hay un segundo apetito que hacer variar. Ninguna astucia en el fixture cambia esto; las dos ramas son genuinamente indistinguibles por la posición del flujo.

Lo dejamos consignado en un comentario justo encima de la prueba:

Si elimina mt_srand($keep) de ng_pick() en concreto, esta prueba sigue en verde, y eso es un límite estructural y no un descuido. No tome una prueba 1 en verde como demostración de que la resiembra de ng_pick() está presente.

La resiembra de ng_pick() se mantiene, por tanto, gracias a la revisión de código y a un comentario en las fuentes — no gracias a la suite. Es una garantía peor, y decirlo es precisamente lo importante. Un punto ciego que ha puesto por escrito es un riesgo conocido. Ese mismo punto ciego sin documentar es una suite de pruebas que miente a quien lea su salida verde la próxima vez, muy probablemente usted mismo dentro de ocho meses.

El otro tipo de prueba que no puede fallar

Las pruebas de mutación encuentran pruebas que están muertas por accidente. También llaman la atención sobre pruebas que nunca estuvieron vivas, y encontramos una de esas en nuestra propia suite.

Teníamos una comprobación que comparaba tres magnitudes por locale: la proporción de un corpus que ocupan sus diez primeras entradas, la cobertura de la población nacional declarada por el corpus, y la proporción de la población que de ahí resulta para esas diez primeras entradas. Para Taiwán, la suite imprime actualmente el 52,81 % del corpus, multiplicado por un 99,9 % de cobertura declarada, lo que da un 52,73 %.

La parte tranquilizadora es el primer número. El Ministerio del Interior de Taiwán publica una proporción de los diez primeros del 52,79 % para el registro completo. Nuestro corpus calcula un 52,81 %. Dos centésimas de punto porcentual de diferencia — exactamente la clase de concordancia que hace que uno deje de mirar.

La diferencia de dos centésimas es explicable y no misteriosa, y merece la pena acotarla, porque un vago «aproximadamente 52,8 %» es justo la forma en que se escondería una discrepancia real. El 52,79 % del registro se calcula sobre 2.731 apellidos y 23.373.283 portadores. Nuestro 52,81 % se calcula sobre 2.707 apellidos y 23.367.536 portadores, porque descartamos 24 filas inservibles: la categoría de servicio 其他 («otros», 5.174 portadores, no es un apellido) y 23 filas de la Private Use Area, donde el ministerio codifica caracteres raros que no tienen asignación Unicode y se mostrarían como cuadros vacíos. Son 5.747 portadores, el 0,0246 % de la población, retirados del denominador. Un denominador ligeramente menor con los mismos diez primeros da una proporción ligeramente mayor. Los números concuerdan porque la limpieza fue pequeña y correcta.

Nada de esto convierte la comprobación de identidad en una prueba. No vale nada como verificación, y no vale nada por una razón visible sin ejecutar nada. Ambos lados se calculan a partir de los mismos pesos almacenados. La cobertura declarada es la suma de esos pesos dividida por la población nacional; la proporción de los diez primeros son los diez primeros de esos mismos pesos divididos por su suma. La identidad se reduce a:

(a/b) × (b/c) = a/c

lo cual es cierto para a, b y c absolutamente cualesquiera. Llene el corpus de números inventados y la comprobación seguirá concordando hasta el segundo decimal. No puede ponerse en rojo con datos malos, porque nunca mira si los datos son buenos — solo si la división funciona.

Hay una cosa que sí puede detectar: una futura refactorización que calcule la cobertura por otra vía y rompa con ello la propia identidad aritmética. Es un valor real, aunque estrecho. Así que la comprobación se quedó, pero fue degradada fuera del conjunto de pruebas, a salida informativa, con un estado PASS fijo y un aviso impreso encima de los números que indica que se trata de coherencia interna y no de verificación.

Esa degradación es el movimiento correcto siempre que se encuentre una de estas. Borrar la comprobación hace perder un número útil. Dejarla como prueba infla la cobertura aparente de la suite con una fila que no puede fallar nunca. Imprimirla como información mantiene el número delante de los ojos y lo mantiene fuera del recuento de lo que realmente se está verificando.

Hay aquí una posdata que encaja en un artículo sobre pruebas que dejan de significar algo sin hacer ruido. El comentario explicativo situado encima de esa comprobación citaba un ejemplo resuelto: el 48,14 % del corpus multiplicado por un 109,8 % de cobertura declarada da un 52,84 %. Cuando ejecutamos la comprobación mientras escribíamos este artículo, imprimió en cambio 52,81 %, 99,9 % y 52,73 %. El comentario no era falso cuando se escribió — describía una compilación en la que el corpus taiwanés estaba truncado a sus 401 apellidos más frecuentes, de modo que los pesos sobrestimaban la población y la cobertura salía por encima del 100 %. El corpus incluye ahora los 2.707 apellidos con recuentos reales de portadores, y la cobertura queda justo por debajo del 100 %. Nada se rompió; simplemente la prosa dejó de coincidir con el código y nada en el mundo nos lo habría dicho jamás. Los comentarios que llevan números son afirmaciones no verificadas con aspecto de documentación, y se degradan exactamente en la dirección en la que siguen resultando plausibles.

Lo que vale una suite verde

Hay tres estados fáciles de confundir y que conviene separar explícitamente:

EstadoLo que le dice ejecutar la suiteLo que no le dice
Código correcto, prueba capazVerde
Código roto, prueba capazRojo
Código correcto, prueba incapazVerdeQue la prueba es decorativa
Código roto, prueba incapazVerdeQue el código está roto

Las filas tres y cuatro son indistinguibles de la fila uno por muchas veces que se ejecute la suite. Solo la mutación las distingue, porque la mutación es lo que le coloca deliberadamente en la fila cuatro y pregunta qué color sale.

Por eso mismo los porcentajes de cobertura son un mal indicador indirecto. Cada una de nuestras once mutaciones vive en código que ya estaba cubierto — ejecutado por la suite, contado en el porcentaje. La cobertura mide si una línea se ejecutó. No dice nada sobre si algo se habría quejado en caso de que esa línea fuera errónea. Nuestra antigua prueba 1 ejecutaba la resiembra en cada pasada y no afirmaba nada sobre ella.

La lista de comprobación

  1. Rompa el código y exija un fallo concreto. No «la suite se puso en rojo» — es esta prueba la que se puso en rojo. De lo contrario, una mutación demasiado amplia permite que una prueba muerta se apunte la detección de una prueba viva.
  2. Ejecute un control sin mutar. Un control en verde es lo que da sentido a los resultados en rojo.
  3. Mute una sola cosa cada vez. Dos mutaciones simultáneas se enmascaran mutuamente, y no se aprende nada de ninguna de las dos.
  4. Elija defectos que alguien podría entregar de forma plausible, no inversiones de operador al azar. Unidades equivocadas en una columna de datos, una caché desactualizada, un índice desplazado, una codificación errónea.
  5. Audite sus fixtures en busca de simplificaciones accidentales. Una reserva que no hace nada, un stub que devuelve una constante, un fixture con una sola fila donde producción tiene miles — así es como una prueba deja de poder fallar sin hacer ruido.
  6. Haga variar aquello a lo que el código dice ser insensible. Si el código afirma ser robusto frente a cuánto consume la reserva, el fixture debe consumir cantidades distintas. Un único valor no comprueba nada sobre la invariancia.
  7. Busque identidades disfrazadas de comprobaciones. Si ambos lados derivan de la misma fuente, escriba el álgebra entera. Si se reduce a algo cierto para todas las entradas, no es una prueba.
  8. Degrade en lugar de borrar una comprobación que no puede fallar: conserve el número como salida informativa, retírelo del recuento de pruebas.
  9. Deje por escrito lo que su suite no puede ver. Un punto ciego documentado es un riesgo gestionado. Uno sin documentar es una falsa garantía con su nombre en ella.

No hicimos este ejercicio porque sospecháramos de la suite. Lo hicimos porque un resultado en verde es infalsable por construcción, y la única respuesta honesta a un resultado infalsable es ir y construir uno mismo la falsación. Costó una tarde, encontró una aserción muerta y una identidad que se hacía pasar por prueba, y convirtió una confianza vaga en una afirmación concreta y escrita: diez defectos que esta suite detecta, y uno que no.

Sobre el invariante en sí y sobre por qué el generador guarda y restaura el flujo, véase Cómo funciona nuestro generador. Sobre el defecto real que imita la mutación 1 — sesenta ciudades que suman el 106,9 % de un país —, véase Sesenta ciudades, el 106,9 % de un país. Sobre la trampa de la coherencia interna en su contexto original, véase Fuentes de datos: Taiwán.


Datos actualizados a 2026-07-18

Todas las cifras se volvieron a derivar el 18 de julio de 2026 a partir de los ficheros en disco, y la campaña de mutaciones se ejecutó el mismo día contra el árbol entregado.

Fuentes y notas:

  • Suite de pruebasng_regression_tests.php, entregada en source_share_1.1.8/set/. Diez pruebas, referenciadas con las claves 1–10 en un registro al principio del fichero: (1) invariante del flujo mt_rand, (2) determinismo de la ficha según la semilla, (3) distribución de los pesos y uniformidad de los ficheros planos, (4) top-10 empírico frente al teórico, (5) suma de las poblaciones de las ciudades frente a la población nacional, (6) identidad del denominador (INFO, no verificación), (7) emparejamiento ciudad ↔ código postal, (8) trampa de la etiqueta de cierre en un comentario de línea, (9) codificación de los datos (BOM, UTF-16, CRLF, UTF-8 mal formado), (10) cohortes de nacimiento que separan realmente las generaciones.
  • Arnés de mutaciónng_regr_mutation_check.php, un script de trabajo más que un artefacto entregado. Once mutaciones, según la tabla anterior; copia el árbol con robocopy, aplica una mutación, ejecuta la suite con --only=N y exige que el estado analizado de la prueba N sea FAIL. Termina con una ejecución de control sin mutar.
  • La campaña del 18 de julio de 2026 — diez detectadas, una no detectada. La que se escapó es la mutación 8 (ng_pick), donde la prueba 1 informó PASS porque ambas ramas mostraban el mismo valor siguiente de mt_rand; es un fallo de detección por construcción, no por accidente. Ejecución de control sobre una copia sin mutar: tests: 10 · PASS 10 · FAIL 0 · WARN 0 · SKIP 0 · ERROR 0 · time 15.1 s.
  • El defecto de la reserva — consignado en las fuentes, junto al fixture, que indica que una reserva que no toca mt_rand hace que ambas ramas realicen exactamente una tirada, de modo que el flujo coincide incluso sin resiembra en el código, y que así fue como la prueba estuvo un día en verde sobre código roto. El fixture actual quema 3 y 10 tiradas respectivamente.
  • El límite estructural — consignado en un comentario encima de la prueba 1, y descrito allí como verificado por mutación y no como supuesto. ng_pick() no tiene rama de reserva, de modo que presente-contra-ausente no puede distinguir la presencia de la resiembra.
  • La identidad del denominador — la prueba 6 lleva un PASS fijo e imprime un aviso de que se trata de coherencia interna, no de verificación. Su salida en vivo del 18 de julio de 2026 es zh_TW lastname_male · 52.81% top-10-in-corpus · 99.9% coverage · 52.73% product, reejecutada para este artículo. El ejemplo 48.14% / 109.8% / 52.84% que sigue en el comentario encima de esa prueba describe una compilación de 401 entradas ya sustituida y está desactualizado; data-sources-taiwan.md se corrigió el 18 de julio de 2026 y lleva ahora las cifras actuales. Obsérvese que el 99,9 % de la suite y el 99,98 % del artículo solo difieren por el denominador — la suite divide por una base redondeada de 23.400.000 y el artículo por la base del registro, 23.373.283.
  • Las cifras taiwanesas — recalculadas directamente a partir de cached/zh_TW/lastname_male.wgt el 18 de julio de 2026: 2.707 entradas, pesos que suman 23.367.536, los diez primeros que suman 12.339.778, lo que da un 52,8074 % del corpus. Frente a la base completa del registro, 23.373.283, esos mismos diez primeros son el 52,7944 %. El ministerio publica un 52,79 % sobre 2.731 apellidos. La diferencia de 5.747 portadores entre los dos denominadores corresponde exactamente a las 24 filas excluidas: 其他 con 5.174 portadores más 23 filas de la Private Use Area. Las dos proporciones son, por tanto, dos mediciones distintas, no una contradicción.

← Artículos