Habilidad: dedup-rust-tests
Las referencias a archivos (@ruta) en esta habilidad son relativas a este directorio.
Lo que hago
Tengo dos modos de operación que trabajan juntos:
-
DEDUP — Identificar y eliminar pruebas unitarias redundantes de Rust donde otra prueba ya cubre las mismas rutas de código con valores de entrada idénticos o equivalentes. Verificado mediante comparación de cobertura
cargo llvm-cov(antes vs. después). -
GAP-FILL — Después de la deduplicación, analizar el informe de
llvm-covpara encontrar código no probado o insuficientemente probado (monomorfizaciones específicas, implementaciones de traits, código condicional por features) y luego escribir pruebas unitarias específicas para llenar esos vacíos. Verificado mediante mejora de cobertura.
Ambos modos comparten un flujo de trabajo común de construcción base y medición de cobertura.
Cuándo usarme
Para DEDUP:
- Tu suite de pruebas Rust ha crecido orgánicamente con múltiples módulos de prueba cubriendo las mismas rutas de código.
- Un módulo de prueba en línea "al estilo antiguo" (
#[cfg(test)] mod tests) coexiste con archivos de prueba separados más nuevos (more_tests.rs) que subsumen las pruebas antiguas. - Una "prueba de humo" (
it_works) duplica aserciones de múltiples pruebas enfocadas. - Heredaste un código base con escenarios de prueba duplicados (ej.,
test_abs+test_fraction_absque prueban el mismo método con entradas superpuestas). - Quieres reducir el tiempo de prueba en CI sin perder cobertura.
Para GAP-FILL:
- Tipos genéricos solo se prueban para un tipo concreto (ej.,
Fraction<i32>); el informe de cobertura revela monomorfizaciones no probadas (Fraction<i8>,Fraction<i128>). - Implementaciones de traits (
From<i8>,TryFrom<...>) muestran cobertura del 0%. - Código condicional por features (
#[cfg(feature = "...")]) no está probado. - Métodos privados (
signum(),is_positive()) no están cubiertos porque solo pueden probarse desde su propio módulo.
NO me uses cuando:
- Las pruebas están basadas en propiedades / generativas (quickcheck, proptest) probando invariantes; estas son intencionalmente redundantes por diseño.
- La prueba es la única que ejercita una ruta de código particular, incluso si los valores parecen similares.
- El proyecto no tiene configurada ninguna herramienta de cobertura de código.
- Solo necesitas agregar pruebas sin verificar primero la redundancia.
Cómo trabajo
Fase 0: Entender el panorama de pruebas
# Listar todos los archivos fuente Rust
Get-ChildItem -Recurse -Filter *.rs
# Encontrar todas las anotaciones y módulos de prueba
Select-String -Path "src/**/*.rs" -Pattern "#\[test\]|#\[cfg\(test\)\]|#\[quickcheck\]|mod tests"
# Contar pruebas por archivo
Select-String -Path "src/**/*.rs" -Pattern "^\\s*fn (test_|check_)" | Group-Object -Property Filename
Mapear la estructura de pruebas:
- Módulos en línea
#[cfg(test)] mod tests { ... }dentro de archivos fuente; aquí son accesibles los métodos privados. - Archivos de prueba de integración independientes
tests/*.rs. - Módulos de prueba condicionales por features (
#[cfg(feature = "...")]). - Pruebas de propiedades quickcheck/proptest; nunca eliminar estas.
- Doc-tests (
/// ``` ... ``); también contribuyen a la cobertura.
Preguntas clave:
- ¿Hay múltiples módulos de prueba cubriendo el mismo código? (ej.,
fractions.rs::testsvsmore_tests.rs) - ¿Hay pruebas "al estilo antiguo" junto a pruebas "completas"?
- ¿Hay una prueba de humo (
it_works) que duplica aserciones de pruebas enfocadas? - ¿Qué parámetros de tipos genéricos se ejercitan?
Fase 1: Detectar redundancia
Nivel 1: Mismas aserciones con valores idénticos
Dos pruebas son redundantes si ejercitan la misma función con valores de entrada idénticos o estrictamente subconjunto:
// test_abs (redundante — subconjunto de test_fraction_abs)
#[test]
fn test_abs() {
assert_eq!(Fraction::new(-3, 4).abs(), Fraction::new(3, 4)); // igual que abajo
}
// test_fraction_abs (conservar — cubre más)
#[test]
fn test_fraction_abs() {
assert_eq!(Fraction::new(-3, 4).abs(), Fraction::new(3, 4)); // duplicado
assert_eq!(Fraction::new(3, -4).abs(), Fraction::new(3, 4)); // único
}
Regla: Si todas las aserciones de la prueba A están contenidas en la prueba B, la prueba A es redundante. Eliminar A.
Nivel 2: Misma ruta de código con valores equivalentes
Dos pruebas son redundantes si ejercitan la misma ruta de función con entradas equivalentes:
// test_mul_div (redundante — subsumida por test_fraction_arithmetic)
#[test]
fn test_mul_div() {
assert_eq!(Fraction::new(3, 4) * Fraction::new(5, 6), Fraction::new(5, 8));
assert_eq!(Fraction::new(3, 4) / Fraction::new(5, 6), Fraction::new(9, 10));
}
// test_fraction_arithmetic (conservar — cubre mul/div Y también add/sub/neg/reciprocal)
#[test]
fn test_fraction_arithmetic() {
assert_eq!(Fraction::new(1, 2) + Fraction::new(1, 3), Fraction::new(5, 6));
assert_eq!(Fraction::new(1, 2) - Fraction::new(1, 3), Fraction::new(1, 6));
assert_eq!(Fraction::new(1, 2) * Fraction::new(1, 3), Fraction::new(1, 6));
assert_eq!(Fraction::new(1, 2) / Fraction::new(1, 3), Fraction::new(3, 2));
assert_eq!(-Fraction::new(1, 2), Fraction::new(-1, 2));
assert_eq!(Fraction::new(1, 2).inv(), Fraction::new(2, 1));
}
Regla: Si la prueba A cubre solo las operaciones X e Y, pero la prueba B cubre X, Y y Z, eliminar A.
Nivel 3: Prueba duplicada entre módulos
La misma función se prueba en dos módulos de prueba separados con escenarios superpuestos:
// fractions.rs::tests::test_fraction_from_i32_to_i64 (redundante)
#[test]
fn test_fraction_from_i32_to_i64() {
let frac: Fraction<i64> = Fraction::from(3_i32);
assert_eq!(frac, Fraction::new(3, 1));
}
// more_tests.rs::test_from_i32_to_i64 (conservar — idéntico, conservar el más nuevo)
#[test]
fn test_from_i32_to_i64() {
let f_i32 = 3;
let f_i64 = Fraction::<i64>::from(f_i32);
assert_eq!(f_i64, Fraction::new(3i64, 1i64));
}
Regla: Cuando las pruebas residen tanto en módulos en línea como separados, y prueban lo mismo de manera idéntica, eliminar la duplicada. Generalmente conservar la que está en el archivo de prueba dedicado.
Nivel 4: Prueba de humo subsumida por pruebas enfocadas
// it_works (prueba de humo redundante — eliminar)
#[test]
fn it_works() {
assert_eq!(const_gcd(4, -6), 2); // cubierto por test_const_gcd
assert_eq!(Fraction::new(30, -40), Fraction::new(-3, 4)); // cubierto por test_fraction_construction
assert_eq!(Fraction::from(30), Fraction::new(30, 1)); // cubierto por test_fraction_construction
assert_eq!(Fraction::<i32>::default(), Fraction::new(0, 1)); // cubierto por test_fraction_construction
}
Regla: Una prueba de humo que duplica aserciones de múltiples pruebas enfocadas debe ser eliminada. No aporta valor de cobertura.
NUNCA eliminar
- Pruebas quickcheck/proptest — se ejecutan con entradas aleatorias y verifican invariantes.
- La única prueba para un tipo específico — incluso si parece similar, ejercita una monomorfización diferente.
- Pruebas que son las únicas que ejercitan una ruta de código — verificar el informe de cobertura.
Fase 2: Construir línea base (ANTES de la eliminación)
# Generar cobertura base
cargo llvm-cov --all-features --workspace --summary-only
# También guardar informe detallado para comparación a nivel de línea
cargo llvm-cov --all-features --workspace --lcov --output-path coverage-before.lcov
Registrar métricas base:
- Porcentajes de cobertura de regiones, líneas, funciones.
- Número de regiones no cubiertas.
- Conteo total de pruebas:
cargo test --all-features --workspace 2>&1 | Select-String "test result: ok. (\d+) passed"
Fase 3: Eliminar las pruebas redundantes
Eliminar cada función de prueba redundante de su archivo usando ediciones exactas. Después de cada lote de eliminación, ejecutar:
cargo test --all-features --workspace
Si la compilación falla debido a importaciones no utilizadas (ej., use num_integer::gcd; que solo se usaba en la prueba eliminada), limpiarlas.
Tareas comunes de limpieza:
- Eliminar importaciones
#[cfg(test)] use ...;que solo se usaban para pruebas eliminadas. - Eliminar importaciones a nivel de módulo
use num_integer::gcd;si solo se usaban en una prueba de humo eliminada. - Eliminar código de prueba comentado que haga referencia a funciones eliminadas.
Fase 4: Reconstruir y recopilar métricas DESPUÉS DE DEDUP
cargo llvm-cov --all-features --workspace --summary-only
cargo llvm-cov --all-features --workspace --lcov --output-path coverage-after-dedup.lcov
Criterios de veredicto para DEDUP:
| Verificación | Requisito |
|---|---|
| Todas las pruebas pasan | Debe pasar |
| Cobertura de líneas por archivo | Sin disminución (aumento es aceptable — menos líneas de prueba en el binario = mayor proporción de cobertura) |
| Número de regiones no cubiertas | Debe disminuir o mantenerse igual |
| Sin nuevas rutas de código descubiertas | Comparar entradas FNDA:0 en lcov antes/después |
Si la cobertura disminuye en algún archivo, revertir inmediatamente: la prueba NO era redundante.
Fase 5: Analizar vacíos de cobertura (modo GAP-FILL)
Analizar el archivo lcov para funciones descubiertas
Las líneas FNDA:0 en el archivo lcov revelan exactamente qué monomorfizaciones no están probadas:
FNDA:0,_RNvMNt...INtB2_8FractionnE7new_rawB4_ # Fraction<type_n>::new_raw — no probado
FNDA:0,_RNvMs2_Nt...INtB5_8FractionpE11is_positiveB7_ # Fraction<type_p>::is_positive — no probado
FNDA:0,_RNvXs7_Nt...INtB5_8FractionlEINtNt...4FromhE4fromB7_ # From<i8> for Fraction — no probado
Patrones comunes de vacíos en Rust:
| Patrón | Ejemplo | Lo que falta |
|---|---|---|
| Monomorfización no probada | Fraction<i8> nunca instanciado | Agregar pruebas con Fraction::<i8>::new(...) |
| Implementación de trait no probada | From<i8> para Fraction<i32> | Agregar prueba Fraction::from(5i8) |
| Implementación TryFrom no probada | TryFrom<Fraction<i64>> para Fraction<i32> | Agregar prueba Fraction::<i32>::try_from(f_i64) |
| Métodos privados | signum(), is_positive() descubiertos para i8 | Debe probarse desde dentro del mismo #[cfg(test)] mod tests |
| Código condicional por features | #[cfg(feature = "serde")] no probado | Agregar prueba con --features serde |
| Neg para tipo específico | Implementación de Neg para Fraction<i8> | Agregar -Fraction::<i8>::new(...) |
Decodificar nombres modificados de lcov
Los nombres modificados de Rust en lcov indican parámetros de tipo. Sufijos clave a buscar:
NtB5_8FractionlE...— Fraction con tipol(comúnmentei64)NtB5_8FractionnE...— Fraction con tipon(comúnmentei8)NtB5_8FractionxE...— Fraction con tipox(comúnmentei128)NtB5_8FractionpE...— Fraction con tipop(comúnmentei8en algunas compilaciones)
El mapeo exacto depende del orden de monomorfización. Referencia cruzada con los tipos realmente usados en pruebas:
i32— casi siempre cubierto (usado en toda la base de código)i64— verificar si está cubierto (a menudo a través deFrom<i32> for Fraction<i64>)i8— raramente cubierto (necesita prueba explícita)i16— raramente cubiertoi128— puede estar cubierto vía quickcheckBigInt— cubierto vía pruebas condicionales por features
Fase 6: Escribir pruebas específicas
Para monomorfizaciones no probadas (i8, i16, etc.)
Escribir una prueba completa que cubra construcción, setters, aritmética, comparación y valores especiales:
#[test]
fn test_i8_construction() {
let f = Fraction::new(1i8, 2i8);
assert_eq!(f, Fraction::new(1i8, 2));
let f_raw = Fraction::new_raw(3i8, 4i8);
assert_eq!(*f_raw.numer(), 3i8);
assert_eq!(*f_raw.denom(), 4i8);
}
#[test]
fn test_i8_setters() {
let mut f: Fraction<i8> = Fraction::new(1, 2);
f.set_zero();
assert!(f.is_zero());
f.set_one();
assert!(f.is_one());
f.set_infinite();
assert!(f.is_infinite());
f.set_nan();
assert!(f.is_nan());
}
#[test]
fn test_i8_arithmetic() {
let f1: Fraction<i8> = Fraction::new(1, 2);
let f2: Fraction<i8> = Fraction::new(1, 3);
assert_eq!(f1 + f2, Fraction::new(5i8, 6));
assert_eq!(f1 - f2, Fraction::new(1i8, 6));
assert_eq!(f1 * f2, Fraction::new(1i8, 6));
assert_eq!(f1 / f2, Fraction::new(3i8, 2));
assert_eq!(-f1, Fraction::new(-1i8, 2));
assert_eq!(f1.abs(), Fraction::new(1i8, 2));
}
Para métodos privados (deben estar en el mismo módulo)
Los métodos privados como signum(), is_positive(), is_negative() solo pueden probarse desde dentro de un bloque #[cfg(test)] mod tests dentro del mismo archivo fuente donde están definidos:
// En fractions.rs, dentro de #[cfg(test)] mod tests:
#[test]
fn test_i8_signum_and_predicates() {
let pos: Fraction<i8> = Fraction::new(3, 4);
assert!(pos.is_positive());
assert!(!pos.is_negative());
assert_eq!(pos.signum(), Fraction::new(1i8, 1));
let neg: Fraction<i8> = Fraction::new(-3, 4);
assert!(neg.is_negative());
assert_eq!(neg.signum(), Fraction::new(-1i8, 1));
let zero: Fraction<i8> = Fraction::new(0, 1);
assert_eq!(zero.signum(), Fraction::new(0i8, 1));
}
Para implementaciones de traits no probadas
#[test]
fn test_from_i8_to_i32() {
let f: Fraction<i32> = Fraction::from(5i8);
assert_eq!(f, Fraction::new(5i32, 1));
}
#[test]
fn test_try_from_i64_to_i32() {
use core::convert::TryFrom;
let f_i64 = Fraction::new(5i64, 1i64);
let f_i32 = Fraction::<i32>::try_from(f_i64).unwrap();
assert_eq!(f_i32, Fraction::new(5i32, 1));
}
Reglas para valores de prueba
- Usar valores pequeños y seguros para tipos estrechos (ej., el máximo de
i8es 127; evitar desbordamiento). - Probar valores tanto positivos como negativos.
- Probar casos límite: denominador cero (= infinito), cero/cero (= NaN), denominador con signo opuesto.
- Probar operaciones de identidad:
-(-x) == x,x * 1 == x,x + 0 == x. - NO probar métodos privados desde archivos de prueba separados; fallarán en la compilación.
Fase 7: Reconstruir y recopilar métricas DESPUÉS DE GAP-FILL
cargo test --all-features --workspace
cargo clippy --all-targets --all-features --workspace
cargo llvm-cov --all-features --workspace --summary-only
Fase 8: Informar mejora de cobertura
| Métrica | Antes | Después | Δ |
|------------------------|-----------|-----------|----------|
| Número de pruebas | N | N' | +Δ |
| Regiones fractions.rs | 93.76% | 98.45% | +4.69% |
| Líneas fractions.rs | 92.77% | 98.97% | +6.20% |
| Funciones fractions.rs| 88.10% | 98.67% | +10.57% |
| Regiones no cubiertas | 70 | 15 | -55 |
Pruebas redundantes eliminadas: 11
Vacios de cobertura llenados: Fraction<i8>, From<i8>, From<u8>, TryFrom<...>