Estaba leyendo un blog sobre la estrategia de pruebas para el enlazador Wild, cuando me topé con un enlace a cargo-mutants, una herramienta que hace pruebas de mutación para Rust. La documentación prometía que la herramienta es fácil de usar y configurar, entonces la puse a prueba. ¡En efecto, funciona muy bien!
Brevemente: las pruebas de mutación sirven para detectar casos donde se introducen errores en el código, pero la suite de pruebas no detecta los errores. Después de hacer los cambios incorrectos, todas las pruebas siguen pasando. Eso quiere decir que hay algo que falta en las pruebas.
Antes de esto, yo sólo había visto mencionadas las "pruebas de mutación" como algo exótico que se puede hacer para probar compiladores, pero no como una herramienta de propósito general. Tal vez no me había fijado con detenimiento.
Configuración y correr cargo-mutants por primera vez
La instalación es sencilla: corres cargo install cargo-mutants y lo
corres como cargo mutants.
En librsvg esto corrió durante varias horas, pero en el proceso
descubrí un par de cosas sobre la forma en que está organizado el
repositorio de librsvg. El repo es un "cargo workspace" con varios
huacales (crates): la implementación de librsvg y la API pública, el
binario rsvg-convert, y algunas utilerías como rsvg-bench.
-
Por default,
cargo-mutantssólo agarra las pruebas pararsvg-convert. Creo que esto pasa porque ese es el único ejecutable en el workspace que tiene una suite de pruebas (e.g.rsvg-benchno tiene pruebas). -
Tuve que correr
cargo mutants --package librsvgpara decirle que considerase la suite de pruebas del huacal delibrsvg, que es la biblioteca principal. Creo que pude haber usadocargo mutants --workspacepara que corriera en todas las cosas; será para otra ocasión.
Resultados iniciales
La primera vez que corrí cargo-mutants sobre rsvg-convert éste produjo resultados útiles: encontró 32 mutaciones en el código de rsvg-convert que deberían haber causado fallas en la suite de pruebas, pero las pruebas no cacharon nada.

La segunda vez, cuando lo corrí sobre todo el huacal de librsvg,
cargo-mutants se tardó unas 10 horas. Es fascinante verlo correr. Al
final encontró 889 mutaciones con bugs que la suite de pruebas debería
haber cachado:
5243 mutants tested in 9h 53m 15s: 889 missed, 3663 caught, 674 unviable, 17 timeouts
¿Qué significa eso?
-
5243 mutants tested: cuántas modificaciones se intentaron en el código. -
889 missed: Las mutaciones importantes: después de hacer una modificación de estas, la suite de pruebas no detectó nada mal. -
3663 caught: ¡Bien! ¡La suite de pruebas atrapó estas mutaciones! -
674 unviable: Estas mutaciones ni siquiera compilaron. Nada qué hacer. -
17 timeouts: Vale la pena investigarlos; tal vez se puede marcar una función para que cargo-mutants la ignore.
Comenzando a analizar los resultados
Debido a la forma en que funciona cargo-mutants, los resultados "missed" salen en un orden arbitrario, divididos entre todos los archivos del código fuente:
rsvg/src/path_parser.rs:857:9: replace <impl fmt::Display for ParseError>::fmt -> fmt::Result with Ok(Default::default())
rsvg/src/drawing_ctx.rs:732:33: replace > with == in DrawingCtx::check_layer_nesting_depth
rsvg/src/filters/lighting.rs:931:16: replace / with * in Normal::bottom_left
rsvg/src/test_utils/compare_surfaces.rs:24:9: replace <impl fmt::Display for BufferDiff>::fmt -> fmt::Result with Ok(Default::default())
rsvg/src/filters/turbulence.rs:133:22: replace - with / in setup_seed
rsvg/src/document.rs:627:24: replace match guard is_mime_type(x, "image", "svg+xml") with false in ResourceType::from
rsvg/src/length.rs:472:57: replace * with + in CssLength<N, V>::to_points
Entonces, comencé por ordenar el archivo missed.txt con los
resultados. Así está mucho mejor:
rsvg/src/accept_language.rs:136:9: replace AcceptLanguage::any_matches -> bool with false
rsvg/src/accept_language.rs:136:9: replace AcceptLanguage::any_matches -> bool with true
rsvg/src/accept_language.rs:78:9: replace <impl fmt::Display for AcceptLanguageError>::fmt -> fmt::Result with Ok(Default::default())
rsvg/src/angle.rs:40:22: replace < with <= in Angle::bisect
rsvg/src/angle.rs:41:56: replace - with + in Angle::bisect
rsvg/src/angle.rs:49:35: replace + with - in Angle::flip
rsvg/src/angle.rs:57:23: replace < with <= in Angle::normalize
Con los resultados en orden, puedo ver claramente cómo cargo-mutants trabaja gradualmente, y va haciendo sus modificaciones en (digamos) todos los operadores aritméticos y lógicos para encontrar cambios que no atraparía la suite de pruebas.
Veamos las dos primeras líneas de lo de arriba, las que se refieren a
AcceptLanguage::any_matches:
rsvg/src/accept_language.rs:136:9: replace AcceptLanguage::any_matches -> bool with false
rsvg/src/accept_language.rs:136:9: replace AcceptLanguage::any_matches -> bool with true
Ahora veamos las líneas correspondientes del código fuente:
... impl AcceptLanguage {
135 fn any_matches(&self, tag: &LanguageTag) -> bool {
136 self.iter().any(|(self_tag, _weight)| tag.matches(self_tag))
137 }
... }
}
Las dos líneas de missed.txt quieren decir que si el cuerpo de la
función any_matches() se remplazara con sólo true o false, en
vez del trabajo que hace, entonces no habría pruebas que fallan:
135 fn any_matches(&self, tag: &LanguageTag) -> bool {
136 false // o true, ninguna versión afectaría las pruebas
137 }
}
¡Esto es malo! Nos indica que la suite de pruebas no verifica que esa función, o que el código circundante, funciona correctamente. Y sin embargo, el reporte de cobertura de pruebas para esas líneas indica que sí se están ejecutando como parte de la suite de pruebas. ¿Qué es lo que ocurre?
Creo que esto es lo que está pasando:
- El huacal
librsvgno tiene pruebas unitarias paraAcceptLanguage::any_matches. - Las pruebas de integración en el huacal
rsvg_convertsí tienen una prueba para su opción de--accept-language, y eso es lo que causa que ese código se ejecute y se muestre en el reporte de cobertura. - Esta ejecución de cargo-mutants es sólo para el huacal de
librsvg, no para la combinación integrada delibrsvgconrsvg_convert.
Si nos ponemos pedantes con respecto al propósito de las pruebas,
rsvg-convert asume que la biblioteca librsvg funciona correctamente.
La biblioteca asegura tener soporte en su API para AcceptLanguage, aunque no
tenga pruebas internas para ello.
Por otro lado, rsvg-convert tiene una prueba para su propia opción
--accept-language, en el sentido de si "¿hemos implementado esa
opción de línea de comandos de forma correcta?", no en el sentido de
"¿librsvg implementa la funcionalidad de AcceptLanguage de forma
correcta?".
Después de añadir una pequeña prueba unitaria para
AcceptLanguage::any_matches en el huacal de librsvg, podemos
volver a ejecutar cargo-mutants sólo para ese archivo
accept_language.rs una vez más:
# cargo mutants --package librsvg --file accept_language.rs
Found 37 mutants to test
ok Unmutated baseline in 24.9s build + 6.1s test
INFO Auto-set test timeout to 31s
MISSED rsvg/src/accept_language.rs:78:9: replace <impl fmt::Display for AcceptLanguageError>::fmt -> fmt::Result with Ok(Default::default()) in 4.8s build + 6.5s test
37 mutants tested in 2m 59s: 1 missed, 26 caught, 10 unviable
¡Fabuloso! Como esperábamos ahora sólo tenemos una mutación en ese
archivo (1 missed). Vamos a mirarla.
La función en cuestión es ahora <impl fmt::Display for
AcceptLanguageError>::fmt, un formateador de errores para el tipo
AcceptLanguageError:
impl fmt::Display for AcceptLanguageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoElements => write!(f, "no language tags in list"),
Self::InvalidCharacters => write!(f, "invalid characters in language list"),
Self::InvalidLanguageTag(e) => write!(f, "invalid language tag: {e}"),
Self::InvalidWeight => write!(f, "invalid q= weight"),
}
}
}
Lo que quiere decir cargo-mutants cuando nos indica que "replace ... -> fmt::Result with
Ok(Default::default()) es que si el cuerpo de esa función se sustituyera por completo por esto:
impl fmt::Display for AcceptLanguageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Ok(Default::default())
}
}
entonces ninguna prueba detectaría el cambio. Ahora bien, esta
función es sólo un formateador para un código de error; el
fmt::Result que devuelve a su vez sólo quiere decir si la llamada a
write!() tuvo éxito. Cuando cargo-mutants descubre que puede
modificar toda la función para que devuelva Ok(Default::default()),
es porque fmt::Result está definido como Result<(), fmt::Error>,
que implementa Default porque el tipo unitario () implementa
Default.
En librsvg, esos errores AcceptLanguageError sólo son visibles como
cadenas de texto en rsvg-convert, de modo que si le pasas un argumento
con un valor incorrecto como --accept-language=foo, entonces el
programa va a escupir el mensaje de error adecuado. Sin embargo,
rsvg-convert no hace promesas acerca del contenido de esos mensajes de
error, entonces creo que es aceptable no poner una prueba para ese
formateador de errores — sólo asegurarse que maneja todos los casos,
lo cual ya se está garantizado por el match. Mi razonamiento es
así:
-
Ya hay pruebas para asegurarse que el código de error se calcula de forma correcta en el parser para
AcceptLanguage; los casos son las variantes de la enumeraciónAcceptLanguageError. -
Sí hay una prueba en rsvg-convert para asegurarse que detecta etiquetas inválidas del idioma y que las reporta correctamente.
Para casos como este, cargo-mutants permite marcar código que debe
saltarse. Después de etiquetar esta implementación de fmt
con #[mutants::skip], ya no hay mutaciones que falte detectar en
accept_language.rs.
¡Victoria!
Entendiendo la herramienta
Definitivamente recomiendo leer "using results" en la documentación de cargo-mutants, que está muy bien escrita. Tiene sugerencias muy buenas de cómo lidiar con las mutaciones que no atrapó la suite de pruebas. Una vez más, estas mutaciones que no se atraparon nos indican cosas faltantes en las pruebas. La documentación indica cómo pensar en qué hacer, y me resultó muy útil.
Después, recomiendo leer sobre los tipos de mutaciones, que
es sobre las variantes de mutaciones que le hace cargo-mutants al
código. Además de cambiar operadores individuales para intentar
calcular resultados incorrectos, también hace cosas como remplazar el
código completo de una función para devolver otro valor. ¿Qué pasa si
una función devuelve Default::default() en vez del valor que habías
calculado cuidadosamente? ¿Qué pasa si una función siempre devuelve
true? ¿Y si una función que devuelve un HashMap siempre devuelve
una tabla de hash vacía, o una llena con e el producto de todas las
llaves y valores posibles? Es decir, ¿en efecto tu suite de pruebas
revisa las invariantes, o tus suposiciones sobre la forma de los
resultados que se calculan? ¡Esto es bien interesante de analizar!
Trabajo futuro para librsvg
La documentación de cargo-mutants sugiere cómo utilizarlo como parte de la Integración Contínua, para asegurarse que no haya mutaciones que no se atrapen al integrar código nuevo. Tal vez intente hacer esto una vez que haya arreglado todas las mutaciones faltantes; eso me va llevar varias semanas por lo menos.
Librsvg ya tiene la magia de gitlab para mostrar cobertura de pruebas en los diffs, entonces sería bueno poder saber si a las pruebas existentes, o a las pruebas nuevas que se van añadiendo, les falta considerar algún caso con el código nuevo de un merge request. Esto se puede detectar con cargo-mutants.
Tarugadas técnicas relevantes para mis pruebas, pero no para este artículo
Si sólo estás leyendo esto para ver de pruebas de mutación, puedes ignorar esta sección. Si te interesan las cuestiones prácticas de la compilación, continúa leyendo.
El código fuente del huacal librsvg usa compilación condicional para
seleccionar si se deben exportar varias funciones que sólo se usan en
las pruebas de integración y en las pruebas internas de la biblioteca.
Por ejmplo, hay algo de código que se usa para obtener la diferencia
entre dos imágenes, que se usa al comparar la salida de pixeles de un
SVG contra una imagen de referencia. Por razones históricas, este
código terminó estando en la biblioteca principal, de modo que ésta
pueda utilizarlo en sus pruebas internas, pero también las pruebas
externas de integración tienen que usar este código. El huacal
librsvg exporta la función de "calcular la diferencia entre dos
imágenes" sólo si está siendo compilado para las pruebas de
integración, y no las exporta para una compilación normal del API
público.
De alguna forma, cargo-mutants no entendió este esquema, y entonces
las pruebas de integración no compilaban porque les faltaba la opción
de cargo para compilar ese código. Intenté habilitarlo a mano con
algo como cargo mutants --package librsvg -- --features test-utils
pero no funcionó.
Entonces, hackée una versión temporal del código fuente sólo para propósitos de las pruebas de mutación. Esta versión siempre exporta las funciones que se usan para comparar imágenes, sin compilación condicional. En el futuro sería posible sacar ese código y ponerlo en un huacal diferente que sólo se use donde se necesite y que nunca se exporte. No estoy seguro de cómo estructurarlo, porque ese código también depende de la representación interna de imágenes que se usa en librsvg. ¿Tal vez mover todo a un huacal separado? ¿Dejar de usar Cairo como la representación de imágenes? ¡Quién sabe!