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

Tests unitarios: la prueba que vive junto al código

En Rust el testing no es una librería que se añade: es parte del lenguaje y de cargo. Un test es una función anotada con #[test]; el arnés la descubre, la ejecuta en paralelo y captura su salida. Las pruebas unitarias habitan en #[cfg(test)] mod tests, dentro del propio fichero, para alcanzar lo privado y desaparecer del binario final.

⏱ 18 min

En casi todos los lenguajes el testing es un añadido: una librería que instalas, un corredor que configuras, una convención que el equipo pacta. En Rust es infraestructura del lenguaje. Una prueba es una función corriente marcada con #[test]; cargo test genera un ejecutable que las recolecta todas, las lanza en paralelo, captura su salida y te informa. Y hay una decisión de diseño que lo distingue de casi todo lo demás: las pruebas unitarias no viven en una carpeta aparte, sino junto al código que prueban, dentro de un módulo #[cfg(test)] mod tests. Esa vecindad no es descuido: es lo que les concede el privilegio de tocar lo privado, y lo que hace que se evaporen del binario que envías a producción. Entender ese módulo, el atributo #[test] y las aserciones es entender cómo Rust convierte la verificación en un ciudadano de primera.

🎯 Al terminar esta lección sabrás
  • Anotar funciones con #[test] y hacerlas fallar mediante panic! o devolviendo Result.
  • Aislar las pruebas en #[cfg(test)] mod tests con use super::* para acceder a lo privado.
  • Comprobar invariantes con assert!, assert_eq!, assert_ne! y mensajes de diagnóstico propios.
  • Gobernar cargo test: filtrado, paralelismo, captura de salida, #[should_panic] e #[ignore].

Un test es una función anotada

El átomo del testing en Rust es una función sin argumentos ni retorno útil, marcada con #[test]. El arnés la considera aprobada si termina con normalidad y fallida si entra en pánico. Nada más:

pub fn suma(a: i32, b: i32) -> i32 {
    a + b
}

#[test]
fn suma_positivos() {
    assert_eq!(suma(2, 3), 5);
}

Cuando ejecutas cargo test, Cargo compila el crate en un modo especial, enlaza un main generado por el arnés de pruebas (el libtest estándar) y ese main invoca cada función #[test] que encuentra. Un pánico en cualquiera de ellas —un assert! roto, un unwrap sobre None— se atrapa en el límite de esa prueba y se traduce en un fallo, sin derribar a las demás.

Una prueba también puede devolver un Result, lo que permite usar el operador ? en su cuerpo y prescindir de una cascada de unwrap:

#[test]
fn parsea_configuracion() -> Result<(), std::num::ParseIntError> {
    let puerto: u16 = "8080".parse()?;
    assert_eq!(puerto, 8080);
    Ok(())
}

Aquí un Ok(()) es aprobación y un Err(_) es fallo. La regla es limpia: el Err viaja por ? y el arnés lo interpreta como prueba caída. La única renuncia es que una prueba que devuelve Result no puede combinarse con #[should_panic].

El módulo de tests: cfg(test) y el privilegio de lo privado

La convención canónica coloca las pruebas unitarias en un submódulo al final del propio fichero:

fn normaliza(x: f64) -> f64 {
    if x.is_nan() { 0.0 } else { x }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nan_se_vuelve_cero() {
        assert_eq!(normaliza(f64::NAN), 0.0);
    }
}

Dos piezas hacen el trabajo. El atributo #[cfg(test)] es compilación condicional: ese módulo entero solo existe cuando Cargo compila en modo prueba. En tu binario de release no queda ni rastro —ni el código de test, ni sus dependencias [dev-dependencies]—, de modo que verificar no engorda lo que envías. Y use super::* trae al alcance todo lo del módulo padre, incluido lo privado: como tests es un módulo hijo, normaliza le es visible aunque no sea pub. Ese es el argumento decisivo para que las pruebas unitarias vivan aquí y no fuera: son pruebas de caja blanca, escritas por quien conoce las tripas y quiere verificar detalles internos que la API pública jamás expone.

Conviene notar el reverso económico de #[cfg(test)]: como el módulo solo existe en modo prueba, sus dependencias exclusivas se declaran en la sección [dev-dependencies] del Cargo.toml, y tampoco viajan al binario final. Un generador de datos de prueba, una librería de aserciones más rica o un cronómetro de benchmarks pesan cero en producción. Verificar, en Rust, nunca engorda lo que envías.

💡
assert_eq! te enseña ambos lados; assert! solo susurra que algo es falso

Prefiere assert_eq!(a, b) a assert!(a == b). El primero, al fallar, imprime los valores de a y b —exige que implementen PartialEq y Debug—, y ese diagnóstico te ahorra media depuración. El segundo solo te dice que una expresión resultó false, obligándote a reconstruir por qué. Reserva assert! para condiciones que no son igualdades.

Aserciones y el gobierno de cargo test

Las tres macros de aserción cubren casi todo: assert!(cond) exige que una condición sea cierta; assert_eq!(a, b) y assert_ne!(a, b) comparan e imprimen ambos operandos al fallar. Todas admiten un mensaje propio con la misma sintaxis de formato que println!, valiosísimo para contextualizar el fallo:

#[test]
fn saldo_no_negativo() {
    let saldo = calcular_saldo();
    assert!(saldo >= 0, "el saldo quedo negativo: {saldo}");
}

Para pruebas que deben entrar en pánico existe #[should_panic], y su forma precisa #[should_panic(expected = "...")] exige además que el mensaje del pánico contenga esa subcadena, evitando que la prueba pase por un pánico equivocado. Y #[ignore] aparca una prueba costosa para que no corra por defecto.

#[test]
#[should_panic(expected = "division por cero")]
fn divide_entre_cero_estalla() {
    dividir(10, 0);
}

assert_eq! y assert_ne!

Comparan dos valores e imprimen ambos al fallar. Exigen PartialEq y Debug. Tu primera opción para verificar igualdad.

🧪

assert! con mensaje

Para condiciones que no son igualdades. Añade un mensaje con formato para que el fallo se explique solo.

💥

should_panic

#[should_panic] aprueba solo si la prueba entra en pánico; con expected = "..." exige que el mensaje contenga una subcadena.

⏭️

ignore

#[ignore] aparca una prueba lenta o frágil; no corre por defecto y se lanza aparte con -- --ignored.

Sobre la línea de comandos, cargo test es rico: ejecuta las pruebas en paralelo por defecto (ajustable con -- --test-threads=1 para forzar orden), captura la salida de las que aprueban y solo la muestra al fallar (o siempre con -- --nocapture), filtra por subcadena del nombre (cargo test saldo), la exige exacta con -- --exact, y corre las marcadas con #[ignore] mediante -- --ignored. El paralelismo por defecto no es un detalle: presiona a que cada prueba sea independiente, sin estado compartido ni orden implícito.

flowchart TD
C[cargo test] --> B[Compila el crate en modo prueba con cfg test activo]
B --> H[El arnes libtest genera un main que recolecta las funciones test]
H --> P[Ejecuta las pruebas en paralelo y captura su salida]
P --> OK[Termina con normalidad igual a aprobada]
P --> F[Entra en panico o devuelve Err igual a fallida]
style C fill:#cba6f7,color:#11111b
style H fill:#89b4fa,color:#11111b
style OK fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b
El testing como propiedad del lenguaje, no como accesorio del proyecto

Que #[test] sea un atributo del compilador y no una macro de una librería externa dice algo profundo sobre la filosofía de Rust: la verificación no es un lujo que cada proyecto negocia, sino una capacidad que viaja con el lenguaje, con una sola forma canónica de hacerla. De esa decisión se desprenden, en cascada, todas las demás. Las pruebas unitarias viven dentro del fichero porque el testing de caja blanca necesita alcanzar lo privado, y un módulo hijo es exactamente el mecanismo de visibilidad que lo permite sin abrir la encapsulación al mundo. El módulo se envuelve en #[cfg(test)] porque la misma maquinaria de compilación condicional que gobierna las plataformas gobierna aquí las pruebas: el código de verificación es real durante cargo test e inexistente en release, sin macros mágicas ni preprocesador, solo el sistema de cfg que ya conoces. El arnés ejecuta en paralelo no por presumir de velocidad, sino porque el paralelismo convierte en error visible cualquier dependencia oculta entre pruebas: si dos comparten un fichero o una variable global, chocarán, y ese choque es una señal, no una molestia. Y que un pánico sea el mecanismo de fallo enlaza el testing con la lección de panic!: una aserción rota es, literalmente, una invariante que tu código juró y no cumplió, capturada en el límite de la prueba en vez de propagarse. La lección de fondo es que en Rust las herramientas no se apilan unas sobre otras como estratos ajenos, sino que emergen de un puñado de principios —compilación condicional, módulos, pánico como bug— reutilizados con coherencia. No aprendes un framework de testing: descubres que el testing es el lenguaje mirándose a sí mismo.

⚔️ Construye tu primer módulo de pruebas
  1. Escribe una función privada fn es_primo(n: u64) -> bool y pruébala desde un #[cfg(test)] mod tests con use super::*; verifica que puedes llamarla pese a no ser pub.
  2. Convierte una de tus pruebas en una que devuelva Result<(), E> y usa ? en su interior; comprueba que un Err la marca como fallida.
  3. Añade un assert! con mensaje propio interpolando el valor culpable, provoca el fallo y lee cómo el diagnóstico te señala el número exacto.
  4. Marca con #[should_panic(expected = "...")] una prueba sobre una función que entra en pánico; luego cambia la subcadena esperada y observa por qué la prueba deja de pasar.
  5. Ejecuta cargo test -- --test-threads=1 y cargo test -- --nocapture; explica con tus palabras qué cambia en cada caso y por qué el paralelismo exige independencia entre pruebas.