wandres.dev
TESTING · unit, integration, doc, property

Mocking, cobertura y CI: la disciplina del proyecto serio

Un proyecto Rust maduro no solo tiene tests: tiene una disciplina. Traits que permiten inyectar dobles de prueba en vez de dependencias reales, mockall para generarlos, cargo-llvm-cov para medir qué código ejercitan de verdad tus pruebas, y una tubería de integración continua que ejecuta test, clippy y fmt en cada push. El testing como práctica de ingeniería, no como acto aislado.

⏱ 20 min

Las lecciones anteriores dieron las piezas: unitarias, de integración, de documentación, de propiedades. Pero un proyecto serio no es quien tiene pruebas, sino quien las convierte en disciplina: en un sistema que se ejecuta solo, mide lo que verifica y bloquea lo que no cumple. Esa disciplina tiene tres frentes. Primero, el aislamiento: cómo probar una función que depende de una base de datos o de la red sin tocarlas, sustituyendo esas dependencias por dobles de prueba —y por qué Rust prefiere hacerlo con traits antes que con mocking pesado—. Segundo, la medición: cargo-llvm-cov te dice qué líneas y ramas ejercitan de verdad tus pruebas, transformando la fe en dato. Tercero, la automatización: una tubería de integración continua que en cada push corre cargo test, cargo clippy y cargo fmt --check, de modo que la calidad deje de depender de la memoria de nadie. Aquí el testing deja de ser un acto y se vuelve una práctica de ingeniería.

🎯 Al terminar esta lección sabrás
  • Aislar dependencias con inyección vía traits y distinguir los tipos de doble de prueba.
  • Generar dobles automáticamente con mockall y saber cuándo un fake artesanal es preferible.
  • Medir qué ejercitan tus pruebas con cargo-llvm-cov y leer la cobertura como un suelo, no como una meta.
  • Diseñar una tubería de CI que ejecute pruebas, clippy y fmt, con matriz de versiones y cargo-nextest.

Aislar dependencias: traits antes que mocks

Para probar una función que consulta una API remota sin depender de la red, sustituyes la dependencia real por un doble. El vocabulario conviene precisarlo: un stub devuelve respuestas fijas; un fake es una implementación ligera pero funcional —un mapa en memoria en vez de una base de datos—; un mock además verifica que se le llamó como se esperaba; un spy registra las llamadas para inspeccionarlas después.

La palanca idiomática en Rust no es una librería, sino el sistema de tipos: defines la dependencia como un trait y haces que tu lógica sea genérica o reciba un dyn. En producción inyectas la implementación real; en pruebas, un doble:

trait Reloj {
    fn ahora(&self) -> u64;
}

fn ha_expirado(reloj: &dyn Reloj, emitido_en: u64, ttl: u64) -> bool {
    reloj.ahora() >= emitido_en + ttl
}

#[cfg(test)]
struct RelojFijo(u64);
#[cfg(test)]
impl Reloj for RelojFijo {
    fn ahora(&self) -> u64 { self.0 }
}

#[test]
fn expira_cuando_pasa_el_ttl() {
    let reloj = RelojFijo(1_000);
    assert!(ha_expirado(&reloj, 900, 50));
}

El tiempo, la fuente de indeterminismo por excelencia, queda bajo control sin librería alguna: un struct de tres líneas tras el trait Reloj. Este patrón —inyección de dependencias mediante traits— es la primera herramienta a considerar, y muchas veces la única necesaria.

🧱

Stub

Devuelve respuestas fijas y predecibles. No verifica nada; solo está ahí para que la lógica pueda avanzar.

🪶

Fake

Implementación ligera pero funcional: un mapa en memoria en vez de una base de datos. El doble que mejor envejece.

🕵️

Mock

Programa expectativas y verifica que se le llamó como se esperaba. Potente, pero acopla la prueba a la interacción.

📋

Spy

Registra las llamadas recibidas para inspeccionarlas después, sin imponer expectativas por adelantado.

mockall: cuando el doble merece generarse

Cuando el trait es amplio y escribir dobles a mano cansa, mockall los genera por ti, con expectativas sobre argumentos, valores de retorno y número de llamadas:

use mockall::automock;

#[automock]
trait Repositorio {
    fn buscar(&self, id: u64) -> Option<String>;
}

#[test]
fn usa_el_repositorio() {
    let mut mock = MockRepositorio::new();
    mock.expect_buscar()
        .with(mockall::predicate::eq(7))
        .times(1)
        .returning(|_| Some("ada".to_string()));

    assert_eq!(mock.buscar(7), Some("ada".to_string()));
}

#[automock] sintetiza MockRepositorio con métodos expect_* para programar el comportamiento y verificar que buscar se llamó una vez con el argumento 7.

⚠️
El mock verifica interacciones; y esa verificación puede volverse en tu contra

Un mock que exige “se llamó a buscar exactamente una vez con el argumento 7” acopla la prueba a cómo colabora tu código, no a qué produce. Si mañana refactorizas y llamas a buscar de otro modo igualmente correcto, la prueba se rompe sin que nada esté mal: es un falso positivo de fallo. Por eso la comunidad Rust se inclina, siempre que puede, por fakes simples que comprueban resultados sobre mocks que comprueban interacciones. Alcanza el mock cuando la interacción es lo que importa; para lo demás, un struct de prueba envejece mejor.

Cobertura con cargo-llvm-cov: el dato, no la fe

¿Ejercitan tus pruebas el código que crees? La cobertura lo mide. La herramienta canónica en 2026 es cargo-llvm-cov, que usa la instrumentación a nivel de fuente de LLVM —precisa hasta la región y la rama, superior al viejo enfoque de grcov sobre información de depuración—:

cargo install cargo-llvm-cov
cargo llvm-cov --html          # informe navegable por lineas y regiones
cargo llvm-cov --summary-only  # resumen en la terminal

El informe pinta cada línea según se ejecutó o no, y desglosa la cobertura de regiones y de ramas: no solo si una línea corrió, sino si ambos lados de un if se visitaron. Ahí es donde la cobertura de líneas, más laxa, engaña. Pero interpretarla bien es media batalla: la cobertura es un suelo, no un techo. Una línea ejecutada sin una aserción que la juzgue no prueba nada; el 100% es alcanzable sin verificar una sola salida. Úsala para descubrir lo que nadie toca —código muerto, ramas de error jamás ejercitadas—, no como un número que perseguir hasta el fetichismo.

Integración continua: la disciplina automatizada

El último eslabón cierra el círculo: que todo lo anterior se ejecute solo, en cada cambio, sin depender de que alguien recuerde. Una tubería de CI —GitHub Actions es el estándar de facto— corre en cada push el trío que define un proyecto sano:

# .github/workflows/ci.yml (fragmento)
jobs:
  verificar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: rustup component add clippy rustfmt
      - run: cargo fmt --all -- --check
      - run: cargo clippy --all-targets -- -D warnings
      - run: cargo test --all-features

Tres guardianes complementarios: cargo fmt --check rechaza formato divergente sin discusiones de estilo; cargo clippy -D warnings convierte cada advertencia del linter en un error que bloquea la fusión; cargo test ejecuta toda la suite. Un proyecto maduro añade una matriz de versiones (estable, beta y la versión mínima soportada o MSRV), sustituye el corredor por cargo-nextest —más rápido y con mejor informe—, y suma auditoría de dependencias con cargo-audit o cargo-deny. La cobertura de cargo-llvm-cov se publica como artefacto o hacia un servicio. Nada de esto exige heroísmo diario: exige configurarlo una vez.

flowchart TD
P[push o pull request] --> CI[La tuberia de CI arranca]
CI --> FMT[cargo fmt check rechaza formato divergente]
CI --> CL[cargo clippy con deny warnings bloquea advertencias]
CI --> T[cargo test o nextest ejecuta la suite]
T --> COV[cargo llvm cov mide regiones y ramas]
CI --> AUD[cargo audit revisa dependencias vulnerables]
FMT --> M{Todo verde}
CL --> M
T --> M
M -->|si| OK[Se puede fusionar]
M -->|no| B[Fusion bloqueada]
style P fill:#cba6f7,color:#11111b
style CI fill:#89b4fa,color:#11111b
style OK fill:#a6e3a1,color:#11111b
style B fill:#f38ba8,color:#11111b
El testing maduro es un sistema que no depende de la virtud de nadie

La distancia entre un aficionado y un ingeniero no se mide en si escribe pruebas, sino en si su calidad sobrevive a un mal día. Las pruebas que hay que recordar ejecutar no se ejecutan; la cobertura que nadie mide se degrada; el formato que se pide en la revisión se convierte en una discusión eterna. La disciplina de esta lección consiste, toda ella, en un mismo movimiento: quitar la calidad de las manos de la voluntad humana y ponerla en un sistema que no se cansa, no olvida y no negocia. Cada pieza es una automatización de una virtud que, dejada al albedrío, falla. La inyección por traits automatiza la testabilidad: al forzarte a expresar tus dependencias como traits, el diseño acoplado se vuelve incómodo y el desacoplado, el camino natural —de nuevo, como con lib.rs frente a main.rs, el sistema de tipos empuja hacia la buena arquitectura sin sermonear—. La cobertura automatiza la honestidad: sustituye el “creo que está bien probado” por un dato que no se deja engañar, siempre que recuerdes que mide ejecución, no verificación, y que perseguir el número por el número es confundir el mapa con el territorio. La CI automatiza la constancia: convierte fmt, clippy y test en condiciones que la máquina impone en cada cambio, de modo que el estado del proyecto no dependa del cansancio del viernes ni de la prisa antes de una entrega. Y observa la coherencia final del ecosistema: clippy con -D warnings aplica al estilo la misma filosofía que Result aplica a los errores y que panic! aplica a los bugs —hacer imposible ignorar lo que no debe ignorarse—. El testing, visto desde aquí, no es una fase que sigue a escribir el código, sino la red de garantías dentro de la cual el código se escribe: una prolongación, hacia el tiempo y hacia el equipo, de la misma idea que anima al compilador. Rust demuestra en compilación lo que puede; la disciplina de testing demuestra, en cada push, todo lo demás. Un proyecto serio no confía en que sus autores sean cuidadosos: construye un sistema donde el descuido no llega a fusionarse.

⚔️ Convierte tus pruebas en una disciplina
  1. Refactoriza una función que dependa del tiempo o de la red para que reciba su dependencia tras un trait; inyecta un fake en pruebas y la implementación real en producción.
  2. Genera un doble con #[automock] de mockall, programa una expectativa con .with(...).times(1).returning(...) y verifica que se cumple; luego rompe la interacción y observa el falso fallo del que advierte la lección.
  3. Mide tu proyecto con cargo llvm-cov --html, encuentra una rama de error nunca ejercitada y escribe la prueba que la cubra; distingue cobertura de línea de cobertura de rama.
  4. Escribe un flujo de GitHub Actions que ejecute cargo fmt --check, cargo clippy -D warnings y cargo test, y comprueba que una advertencia de clippy bloquea la fusión.
  5. Añade una matriz con la versión estable y tu MSRV, sustituye el corredor por cargo-nextest, y suma un paso de cargo-audit; razona qué garantiza cada eslabón que los demás no cubren.