Tus tests pasan. Tu código sigue estando mal.
Tienes un 100 % de cobertura de líneas. Cada branch se ejecuta. Cada función se llama. Entonces alguien cambia un + por un - en tu lógica de precios, ejecuta los tests y todos pasan.
Eso no es un problema teórico. Es lo que ocurre cuando tus tests ejecutan el código pero en realidad no verifican el comportamiento. La cobertura mide qué líneas se ejecutan, no qué salidas se comprueban. El mutation testing cierra esa brecha introduciendo pequeños errores a propósito y verificando que tus tests los detecten.
La pregunta para los equipos de Rust no es si el mutation testing es una buena idea. Es si cargo-mutants, la herramienta dominante en el ecosistema, es práctico dado los tiempos de compilación de Rust y su sistema de tipos. La respuesta es sí, con matices que importan.
Qué hace realmente el mutation testing
El mutation testing es simple en concepto. La herramienta hace un cambio minúsculo en tu código fuente, ejecuta tu suite de tests y comprueba si algo falla.
Si la suite de tests falla, el mutante es “matado”. Eso es lo que quieres. Significa que tus tests notaron el error.
Si la suite de tests pasa, el mutante “sobrevive”. Eso significa que tus tests ejecutaron el código mutado y no notaron que nada iba mal. Tienes un test débil.
Las mutaciones comunes incluyen reemplazar operadores aritméticos (+ se convierte en -), intercambiar operadores de comparación (> se convierte en >=), reemplazar literales booleanos (true se convierte en false) y eliminar llamadas a funciones que devuelven valores. Cada cambio es lo suficientemente pequeño como para que un humano lo reconozca como un error. La suite de tests también debería reconocerlo.
Cómo funciona cargo-mutants en código Rust
cargo-mutants es una herramienta de mutation testing construida específicamente para Rust. No requiere que anotes tus tests ni que cambies tu sistema de compilación. La instalas y la ejecutas.
cargo install cargo-mutants
cargo mutants
La herramienta escanea tus archivos fuente, genera mutantes aplicando reglas de transformación al AST y ejecuta cargo test para cada uno. Rastrea qué mutantes sobreviven e imprime un informe.
Aquí tienes una función con un test que parece sólido pero no lo es:
pub fn apply_discount(price: f64, rate: f64) -> f64 {
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_discount() {
let result = apply_discount(100.0, 0.2);
// We ran the function. Coverage is 100%.
// But we never asserted the result.
}
}
cargo mutants generará un mutante que cambia el * por / o reemplaza 1.0 - rate por 1.0 + rate. El test seguirá pasando porque nunca comprueba result. El mutante superviviente señala el problema.
Un test real que mata al mutante se ve así:
#[test]
fn test_apply_discount() {
assert_eq!(apply_discount(100.0, 0.2), 80.0);
assert_eq!(apply_discount(50.0, 0.0), 50.0);
}
Ahora cada mutante aritmético falla porque las aserciones detectan la salida incorrecta.
Cómo se ve la salida
Ejecuta cargo mutants y obtienes un resumen:
Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants
Los mutantes “missed” son los que sobrevivieron. cargo mutants escribe cada uno en mutants.out/ con el diff y la ruta del archivo. Lees el diff y añades la aserción que falta.
Los timeouts ocurren cuando un mutante causa un bucle infinito. cargo-mutants lo detecta y lo marca como matado por timeout, lo que cuenta como un éxito.
Los mutantes “unviable” son cambios que no compilan. El sistema de tipos de Rust los rechaza antes de que los tests se ejecuten.
El sistema de tipos de Rust es un arma de doble filo
En JavaScript o Python, las herramientas de mutation testing pueden reemplazar casi cualquier operador y el código seguirá ejecutándose. Simplemente producirá resultados incorrectos. En Rust, muchas mutaciones son detectadas por el compiler antes de que los tests se ejecuten.
Reemplaza + por - en enteros sin signo y podrías obtener un desbordamiento, pero el código compila. Reemplaza > por < en un contexto genérico y el compiler podría rechazarlo si los límites de trait no soportan la comparación. Elimina una llamada a función que devuelve un valor que el llamador espera, y el compiler da un error.
Esto significa que cargo-mutants genera menos mutantes viables que herramientas equivalentes en otros lenguajes. Un proyecto de Python podría ver 200 mutantes para un module. Un proyecto de Rust podría ver 40. Los mutantes que sí compilan son los que podrían colarse realmente en producción. El sistema de tipos filtra el ruido.
La contrapartida es el tiempo de compilación. Cada mutante viable desencadena una reconstrucción. Un proyecto con una suite de tests de cinco minutos podría pasar una hora ejecutando cargo mutants.
El impuesto del tiempo de compilación es real
Esta es la razón principal por la que los equipos dudan. El mutation testing es embarazosamente paralelo en teoría. Cada mutante es independiente. En la práctica, el sistema de compilación de Rust no se paraleliza limpiamente entre decenas de invocaciones del compiler sobre el mismo árbol de fuentes.
cargo-mutants tiene una flag --jobs, pero la E/S de disco y el bloqueo del grafo de crates se convierten en cuellos de botella. En un runner de CI típico con dos cores, el trabajo escala mal.
Puedes mitigarlo. Usa --in-place para evitar copiar el árbol de fuentes para cada mutante. Usa --file o --exclude para apuntar a modules específicos. Ejecuta el mutation testing por las noches o semanalmente, no en cada push.
Qué se le escapa a cargo-mutants
Ninguna herramienta de mutation testing lo atrapa todo. cargo-mutants tiene limitaciones específicas que deberías conocer.
No muta expansiones de macros. Si tu lógica crítica vive dentro de una macro, la herramienta ve la invocación, no el código generado.
No entiende la equivalencia semántica. Algunos mutantes producen un comportamiento diferente pero aún correcto para todas las entradas válidas. Un + 0 redundante podría sobrevivir porque a los tests no les importa, aunque la mutación no sea un error real. Tienes que hacer triage de estos manualmente.
Cuándo merece la pena el mutation testing
No necesitas ejecutar cargo mutants en cada commit. Lo necesitas cuando tu suite de tests es lo suficientemente grande como para que ya no confíes en tus propias aserciones.
Ejecútalo cuando un module crítico tenga alta cobertura pero hayas enviado errores de todos modos, o cuando un refactor haya cambiado la lógica de forma sutil y quieras confianza de que las aserciones son estrictas.
No lo ejecutes cuando tu suite de tests ya es flaky o cuando tus tiempos de compilación son el cuello de botella del que todo el mundo se queja. Arregla lo fundamental primero.
Añadirlo a CI sin romper el pipeline
La configuración práctica es un job programado, no una barrera en cada pull request.
Aquí tienes un workflow de GitHub Actions que se ejecuta semanalmente:
name: Mutation Testing
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-mutants
run: cargo install cargo-mutants
- name: Run mutation testing
run: cargo mutants --in-place
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-report
path: mutants.out/
La flag --in-place mantiene el uso de disco razonable. rust-cache reduce el tiempo de compilación inicial. El trigger programado evita bloquear a los desarrolladores. Sube el informe como artifact para que puedas revisar los mutantes supervivientes sin tener que desplazarte por los logs de CI.
Empieza por un module
No necesitas mutar toda tu codebase. Elige un module con lógica crítica para el negocio y un historial de errores. Ejecuta cargo mutants --file src/pricing.rs. Lee el informe. Arregla el test más débil.
La primera ejecución siempre es la peor. Encontrarás tests que ejecutan el código pero no aserciones. Encontrarás branches cubiertas por tests que no comprueban el resultado de la branch. Te preguntarás cómo esos tests se sintieron adecuados alguna vez.
Ese es el punto. El mutation testing no encuentra errores en tu código. Encuentra errores en tus tests. En Rust, donde el compiler ya atrapa los errores obvios, ese es exactamente el bucle de feedback que necesitas.
Preguntas frecuentes
¿Qué es el mutation testing?
El mutation testing evalúa tu suite de tests introduciendo pequeños errores deliberados en tu código fuente. Si tus tests fallan, el mutante es “matado”. Si tus tests pasan, el mutante “sobrevive” y tienes una brecha.
¿En qué se diferencia el mutation testing de la cobertura de código?
La cobertura mide qué líneas se ejecutaron. El mutation testing mide si tus tests detectarían una salida incorrecta de esas líneas. Un test puede tener un 100 % de cobertura y no matar ningún mutante.
¿Es lento el mutation testing para todos los proyectos de Rust?
El coste escala con el tiempo de compilación y el número de tests. Las bibliotecas pequeñas pueden terminar en minutos. Los proyectos grandes con workspace tardan significativamente más. Usa --file y --exclude para limitar las ejecuciones a modules específicos.
¿Puedo ignorar mutantes falsos positivos?
Sí. cargo-mutants soporta un archivo de configuración mutants.toml para excluir archivos, funciones o tipos de mutación específicos. Úsalo con moderación para no enmascarar brechas de tests reales.