Siempre pasa lo mismo. Escribes pruebas, la cobertura sube, el dashboard se pone verde, y en algún momento te preguntas si esas pruebas son buenas o solo te están dando una sensación de seguridad.

Quise responderme eso. Hoy se escribe y se revisa código mucho más rápido con un modelo al lado, y lo difícil pasó a ser saber si lo que sale de ahí está bien.

Tienes esta función:

def suma(a, b):
    return a + b

Y esta prueba:

assert suma(2, 2) == 4

Pasa. Cobertura 100%.

Ahora cambia el + por un *. 2 * 2 = 4. La prueba sigue pasando.

Tu suite no distingue una suma de una multiplicación. Y tampoco de una potencia, porque 2² = 4.

Quién vigila a los vigilantes

Una prueba afirma algo: "si el código deja de hacer X, yo fallo". Esa afirmación casi nunca se verifica.

Es un detector de humo. Lo instalas y no suena. El silencio puede ser que no hay incendio o que la pila está muerta, y desde afuera se ven igual. Para saberlo hay que apretar el botón de prueba, que hace un incendio falso a propósito.

Mutation testing es ese botón

La idea la propuso Richard Lipton en 1971, siendo estudiante, y DeMillo, Lipton y Sayward la desarrollaron y publicaron en 1978. Es mecánica: la herramienta rompe una sola cosa de tu código, corre tu suite y anota si alguien se dio cuenta.

El ciclo de la mutación: se rompe una cosa, se corre la suite, y el mutante queda muerto o vivo

Cada ruptura es un mutante. La función de arriba genera seis, uno por cada operador que puede reemplazar al +.

Los seis mutantes de suma(a, b) contra dos casos de prueba: con suma(2, 2) sobreviven el producto y la potencia, con suma(2, 3) mueren todos

Por qué estuvo cincuenta años guardada

Corres la herramienta sobre un archivo real y te devuelve seiscientos sobrevivientes, cada uno sin explicación. Ahí empieza el trabajo pesado, porque hay que leerlos uno por uno y decidir si falta una prueba, si es código muerto, o si es un cambio que no altera el comportamiento y da igual que sobreviva. Eso son días de una persona por cada módulo, y sospecho que por ahí va buena parte de que la técnica se quedara tanto tiempo en la academia.

Y eso es lo que cambió. Corrí ~3.400 mutantes sobre seis módulos de Frauddi en una tarde. Le pasé los resultados a un modelo y me los agrupó: cuáles apuntaban a lo mismo, cuáles podía ignorar, qué revisar primero. El criterio lo puse yo, pero llegar a lo que importaba pasó de días a minutos.

Qué encontró

Pruebas que no comprobaban lo que yo creía. Tres ejemplos, todos míos.

La prueba que espera el valor por defecto. Si una función termina devolviendo un valor por defecto y tu prueba espera justo ese valor, rompas lo que rompas ahí dentro la prueba pasa igual.

def nivel(puntos):
    if puntos > 100:
        return "oro"
    if puntos > 50:
        return "plata"
    return "bronce"

assert nivel(10) == "bronce"

Esa prueba solo recorre la última línea. Cambia los > por >= y sigue en verde.

El límite que nadie comprobaba. Escribí 54 casos para un detector. Mis dos casos de una señal estaban tan lejos del límite que la activa que ninguno lo comprobaba.

def es_sospechoso(ratio):
    return ratio > 0.20

assert es_sospechoso(0.71) is True
assert es_sospechoso(0.02) is False

Mueve ese 0.20 al 0.50 y las dos pruebas siguen pasando. Podía ponerlo donde quisiera con los 54 casos en verde.

La operación que daba igual. Una línea suma dos números, y en todas mis pruebas uno de ellos valía cero.

def posicion(inicio, n):
    return inicio + n

assert posicion(0, 3) == 3

Con inicio en cero, cambiar ese + por | o por ^ da el mismo resultado. Es el suma(2, 2) del principio, en producción y escrito por mí.

De ahí salió la única regla que me llevo: si sobrevive una operación matemática, revisa si tu caso de prueba la vuelve irrelevante.

El indicador que más me sirvió

Una función donde no muere ni un solo mutante: la puedes romper entera y nadie se queja. Apareció tres veces y las tres había algo que limpiar:

Esas pruebas las escribió un modelo y yo las di por buenas al revisarlas. Se leían bien y la cobertura subía, y ninguno de los dos se dio cuenta de que no estaban comprobando nada.

Después de la primera pasada

Eso fue la primera pasada. Después extendí el ejercicio al resto de la aplicación, con pruebas de punta a punta, fuzzing, unitarias e integración. De todas las técnicas, la mutación fue la que más sacó a la luz.

Cuándo vale la pena

Sale caro en cómputo. Cada mutante es una corrida completa de la suite, y hacen falta miles para que aparezca un puñado. No va en CI, y no la corrí sobre todo el código.

Y tiene un orden. Primero subes cobertura, después mides si sirvió. Una línea sin cobertura tiene todos sus mutantes vivos por definición, así que medirla no te dice nada.

Lo que esto dice de los LLMs

Se habla mucho de que los modelos nos van a traer técnicas nuevas. Aquí pasó otra cosa: una técnica de hace cincuenta años se volvió práctica sin cambiar nada.

Vale la pena mirar hacia atrás con eso en mente. Hay ideas que se archivaron porque pedían demasiadas horas de alguien leyendo y clasificando, y justo en esa parte un modelo te puede ayudar bastante.

Si te da curiosidad, agarra un módulo, uno solo, del código que más te importe, y córrele mutación. Lo que salga te va a decir más sobre tus pruebas que cualquier porcentaje de cobertura.