Pruebas avanzadas en Rust: mocks, tests de propiedades y benchmarks
Ya sabes escribir una prueba con #[test] y assert_eq! (si no, empieza por Pruebas en Rust). Para programas pequeños, eso basta. Pero en un proyecto real pronto aparecen preguntas nuevas. ¿Dónde deben ir las pruebas? ¿Cómo pruebas código que envía correos o consulta la hora? ¿Cómo encuentras justo esa entrada en la que no habías pensado? ¿Y tu versión “más rápida” de verdad es más rápida?
Piénsalo como un taller mecánico. Las pruebas básicas comprueban que cada pieza funciona. Esta página añade el resto del taller: un banco de pruebas para piezas (fakes y mocks), una máquina que sacude el coche con miles de baches al azar (tests de propiedades), una foto de cómo estaba todo la última vez (snapshots) y un cronómetro en el que puedes confiar (benchmarks).
Una aclaración honesta antes de empezar: el botón Run del Playground ejecuta main, no las pruebas, y no tiene los crates de pruebas que usamos más abajo. Por eso la mayoría de los ejemplos muestran la salida real de cargo test y cargo bench de un proyecto pequeño en un ordenador normal. Cuando un programa tiene main, verás el botón Pruébalo tú mismo como siempre.
Organizar tus pruebas
Sección titulada «Organizar tus pruebas»Esta es la estructura de la pequeña biblioteca shop que usamos en esta página:
shop/├── Cargo.toml├── src/│ ├── lib.rs <- código + pruebas unitarias en `mod tests`│ └── orders.rs├── tests/│ ├── checkout.rs <- una prueba de integración│ └── common/│ └── mod.rs <- funciones auxiliares compartidas por las pruebas de integración└── benches/ └── stock.rs <- un benchmarkRust tiene tres tipos de pruebas, y cargo test las ejecuta todas:
- Pruebas unitarias (unit tests): viven junto al código, en un bloque
#[cfg(test)] mod tests. También pueden ver las funciones privadas. - Pruebas de integración (integration tests): viven en la carpeta
tests/. Cada archivo se compila como un crate aparte que usa tu biblioteca desde fuera, igual que lo haría un usuario. Por eso solo pueden llamar a elementospub. - Doc tests: son los ejemplos de código dentro de los comentarios
///. Demuestran que tu documentación sigue funcionando. La página de Documentación en Rust los explica en detalle.
Aquí tienes parte de src/lib.rs. Fíjate en el doc test de apply_discount y en las funciones auxiliares (helpers) y fixtures dentro de mod tests:
/// Una línea de un carrito de compra. Los precios van en céntimos.#[derive(Debug, Clone, PartialEq)]pub struct Item { pub name: String, pub price_cents: u32, pub qty: u32,}
pub fn cart_total(items: &[Item]) -> u32 { items.iter().map(|i| i.price_cents * i.qty).sum()}
/// Descuenta un `percent` (porcentaje) de un precio.////// ```/// use shop::apply_discount;////// assert_eq!(apply_discount(1000, 25), 750);/// ```pub fn apply_discount(price_cents: u32, percent: u32) -> u32 { assert!(percent <= 100, "discount must be at most 100%, got {percent}%"); price_cents - price_cents * percent / 100}
/// Convierte "12.50" en 1250 céntimos.pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Convierte 1250 céntimos en "12.50".pub fn format_price(cents: u32) -> String { format!("{}.{:02}", cents / 100, cents % 100)}
#[cfg(test)]mod tests { use super::*;
// Un helper: corto de llamar, y todas las pruebas crean los items igual. fn item(name: &str, price_cents: u32, qty: u32) -> Item { Item { name: name.to_string(), price_cents, qty } }
// Un fixture: un carrito realista que comparten varias pruebas. fn sample_cart() -> Vec<Item> { vec![item("coffee", 450, 2), item("bagel", 300, 1)] }
#[test] fn total_of_sample_cart() { assert_eq!(cart_total(&sample_cart()), 1200); }
#[test] fn empty_cart_costs_nothing() { assert_eq!(cart_total(&[]), 0); }
#[test] #[should_panic(expected = "at most 100%")] fn discount_over_100_panics() { apply_discount(1000, 150); }
// Una prueba puede devolver Result, así puedes usar `?` dentro. #[test] fn parses_a_price() -> Result<(), String> { let cents = parse_price("12.50")?; assert_eq!(cents, 1250); Ok(()) }
#[test] #[ignore = "slow: checks ten million prices"] fn every_price_round_trips() { for cents in 0..10_000_000 { assert_eq!(parse_price(&format_price(cents)), Ok(cents)); } }}Algunas cosas que vale la pena notar:
- Los helpers y los fixtures son simples funciones. Rust no tiene un sistema especial de fixtures, y casi nunca lo echarás de menos. Como están dentro de
#[cfg(test)], nunca acaban en tu programa real. #[should_panic(expected = "...")]solo pasa si el mensaje del panic contiene ese texto, así que un panic causado por otro bug no cuenta. Pruebas en Rust lo explica con más ejemplos.- Las pruebas que devuelven
Resultte dejan usar?. Si la prueba devuelveErr, falla y muestra el error, por ejemploError: "missing '.' in \"12,50\"". #[ignore]se salta por defecto una prueba lenta o especial. El texto después de=es el motivo, ycargo testlo muestra.
Los mensajes y nombres de las pruebas se quedan en inglés para que coincidan con la salida real de cargo test que verás a continuación.
La prueba de integración usa la biblioteca a través de su API pública. Su helper compartido vive en tests/common/mod.rs:
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> { vec![ Item { name: "coffee".into(), price_cents: 450, qty: 2 }, Item { name: "bagel".into(), price_cents: 300, qty: 1 }, ]}mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]fn discounted_breakfast_total() { let total = cart_total(&common::breakfast_cart()); let to_pay = apply_discount(total, 10); println!("to pay: {}", format_price(to_pay)); assert_eq!(format_price(to_pay), "10.80");}Al ejecutar cargo test verás una sección por cada tipo de prueba:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 testtest discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 testtest src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sElegir qué pruebas ejecutar
Sección titulada «Elegir qué pruebas ejecutar»En el día a día no siempre quieres ejecutar todas las pruebas. Estos comandos te ayudan. Todo lo que va después de un -- suelto se le pasa al propio programa de pruebas, no a Cargo.
cargo test discount # solo pruebas cuyo nombre contiene "discount"cargo test --test checkout # solo el archivo tests/checkout.rscargo test --lib # solo las pruebas unitarias de src/cargo test --doc # solo los doc testscargo test -- --ignored # solo las pruebas con #[ignore]cargo test -- --include-ignored # todo, también las ignoradascargo test -- --nocapture # muestra la salida de println! de las pruebas que pasancargo test -- --test-threads=1 # ejecuta las pruebas de una en unaEl filtro busca una parte del nombre completo de la prueba, en todos los archivos de pruebas. Aquí discount encuentra una prueba unitaria y una de integración:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 testtest tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 testtest discounted_breakfast_total ... okLa prueba lenta solo se ejecuta cuando la pides. Diez millones de idas y vueltas tardaron unos 3 segundos aquí:
running 1 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20sPor defecto, Rust oculta la salida de println! de las pruebas que pasan, para que el informe quede limpio. --nocapture la muestra:
$ cargo test --test checkout -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... okDobles de prueba: fíngelo con un trait
Sección titulada «Dobles de prueba: fíngelo con un trait»Hay código que habla con el mundo exterior: envía correos, llama a una API o consulta el reloj. No quieres que tus pruebas envíen correos de verdad. Y tampoco puedes probar “qué pasa a las 9 de la noche” si el código siempre le pregunta la hora al reloj real.
La respuesta idiomática en Rust no necesita ninguna biblioteca. Describe la dependencia como un trait, haz que tu función acepte cualquier cosa que lo implemente y escribe un pequeño fake (una versión falsa) para las pruebas. El programa real pasa la pieza real. Las pruebas pasan el fake.
Aquí, al confirmar un pedido se envía un correo al cliente, y los pedidos grandes también avisan al equipo de ventas:
trait Mailer { fn send(&mut self, to: &str, body: &str);}
// El mailer real. En una app de verdad, llamaría a un servicio de correo.struct ConsoleMailer;
impl Mailer for ConsoleMailer { fn send(&mut self, to: &str, body: &str) { println!("correo a {to}: {body}"); }}
struct Order { id: u32, email: String, total_cents: u32,}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) { let body = format!("Pedido #{} confirmado", order.id); mailer.send(&order.email, &body); if order.total_cents >= 10_000 { }}
fn main() { let mut mailer = ConsoleMailer; confirm_order(&small, &mut mailer); confirm_order(&big, &mut mailer);}
#[cfg(test)]mod tests { use super::*;
// Un fake: no envía nada, solo apunta cada llamada. #[derive(Default)] struct FakeMailer { sent: Vec<(String, String)>, }
impl Mailer for FakeMailer { fn send(&mut self, to: &str, body: &str) { self.sent.push((to.to_string(), body.to_string())); } }
#[test] fn small_order_sends_one_email() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}Salida:
correo a [email protected]: Pedido #7 confirmadocorreo a [email protected]: Pedido #8 confirmadocorreo a [email protected]: Pedido grande #8Este ejemplo no usa crates externos, así que en el Playground también puedes elegir Test en el menú que está junto a Run:
running 2 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sA confirm_order no le importa qué mailer recibe, ni lo sabe. Ese es todo el truco. La misma idea sirve para un trait Clock (las pruebas usan un reloj parado a las 9 de la noche), un PaymentGateway o un trait Storage que en las pruebas usa un HashMap. Más abajo practicarás la versión del reloj.
Mocks con mockall
Sección titulada «Mocks con mockall»Escribir fakes a mano está bien para uno o dos traits. Cuando tienes muchos, el crate mockall (en inglés) puede generarlos por ti. Añádelo como dev-dependency (dependencia de desarrollo), para que solo se use en pruebas y ejemplos (Cargo y crates explica las secciones de Cargo.toml):
cargo add --dev mockallPon #[automock] sobre el trait y mockall crea un tipo MockMailer. La parte cfg_attr(test, ...) significa “haz esto solo al ejecutar pruebas”, así que el mock nunca acaba en tu build real. Features de Cargo y #[cfg] explica cfg con más detalle.
#[cfg(test)]use mockall::automock;
#[cfg_attr(test, automock)]pub trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Order y confirm_order son iguales que arriba.
#[cfg(test)]mod tests { use super::*;
#[test] fn big_order_also_tells_sales() { let mut mailer = MockMailer::new(); mailer .expect_send() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
confirm_order(&order, &mut mailer); }}Cada expect_send() es una expectativa (expectation): “espero que se llame a send con argumentos que cumplan withf, exactamente una vez”. return_const(()) dice qué devuelve el mock (aquí, nada). Cuando el mock se destruye al final de la prueba, mockall comprueba que se cumplieron todas las expectativas.
Para verlo atrapar un bug, cambia la regla de confirm_order a order.total_cents > 12_000. Ahora el correo a ventas nunca se envía:
thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1El mensaje lo escribe mockall en inglés: dice que la expectativa se cumplió 0 veces, menos de la 1 que esperaba.
¿Fake o mock? Empieza con un fake escrito a mano. Es Rust normal, fácil de leer y no añade dependencias. Recurre a mockall cuando tengas muchos traits que fingir, o cuando de verdad te importe que algo “se llame exactamente N veces con estos argumentos”.
Tests basados en propiedades con proptest
Sección titulada «Tests basados en propiedades con proptest»Una prueba normal comprueba un ejemplo que tú elegiste: “12.50 se convierte en 1250”. Un test de propiedades (property test) describe una regla que debería cumplirse para cualquier entrada, y luego una biblioteca prueba cientos de entradas aleatorias para intentar romperla. Es como contratar a alguien muy paciente para probar todos los valores raros en los que no pensaste.
Las buenas propiedades suelen parecerse a estas:
- Hacer algo dos veces lo deshace: invertir una lista dos veces devuelve la lista original.
- Una ida y vuelta te devuelve lo que tenías al principio:
parse(format(x)) == x. - Algún hecho se cumple siempre: una lista ordenada tiene la misma longitud que la de entrada.
Añade proptest (en inglés) con cargo add --dev proptest y escribe las pruebas dentro de la macro proptest!. Cada línea nombre in estrategia indica qué tipo de valores aleatorios generar:
use proptest::prelude::*;use shop::{format_price, parse_price};
proptest! { #[test] fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) { let mut copy = list.clone(); copy.reverse(); copy.reverse(); prop_assert_eq!(copy, list); }
#[test] fn price_survives_format_then_parse(cents in 0u32..10_000_000) { prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents)); }}any::<i32>() significa “cualquier i32”, 0u32..10_000_000 significa un número en ese rango y vec(..., 0..100) crea listas de hasta 99 elementos. Por defecto, cada propiedad se prueba con 256 casos aleatorios. Dentro de proptest! usa prop_assert_eq! en lugar de assert_eq!, para que proptest pueda gestionar bien el fallo. Las dos pasan:
running 2 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sFíjate en que es la misma comprobación que la prueba lenta con #[ignore] de antes. Esa prueba revisaba los diez millones de precios en 3 segundos. El test de propiedades prueba 256 al azar en una centésima de segundo, así que puede ejecutarse siempre.
Ahora cometamos un error a propósito. Alguien “mejora” format_price para que muestre un separador de miles, como 1,000.00. Queda más bonito, pero se olvida de parse_price:
pub fn format_price(cents: u32) -> String { let euros = cents / 100; let euros = if euros >= 1000 { format!("{},{:03}", euros / 1000, euros % 1000) } else { euros.to_string() }; format!("{euros}.{:02}", cents % 100)}Esta es la salida real de cargo test --test properties, un poco recortada:
---- price_survives_format_then_parse stdout ----proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:Test failed: assertion failed: `(left == right)` left: `Err("bad euros in \"1,000.00\"")`, right: `Ok(100000)` at tests/properties.rs:15.minimal failing input: cents = 100000Mira minimal failing input: cents = 100000 (“entrada mínima que falla”). El primer valor aleatorio que falló seguramente fue algo feo como 7,384,912. Luego proptest lo reduce (shrinking): sigue probando valores más pequeños que también fallen y te muestra el más simple. 100000 céntimos son exactamente 1,000.00, el primer precio que lleva coma. Eso te lleva directo al bug.
Proptest también guarda el caso que falla en un archivo .proptest-regressions junto a la prueba, y lo prueba primero en cada ejecución posterior. Haz commit de ese archivo, para que el bug nunca pueda volver sin que te enteres. Si prefieres una biblioteca más pequeña, quickcheck (en inglés) hace el mismo trabajo con menos funciones.
Tests de snapshot con insta
Sección titulada «Tests de snapshot con insta»Hay salidas largas: un recibo, un informe de errores, un documento JSON grande. Escribir assert_eq! con todo el texto esperado cansa. Un test de snapshot guarda la salida en un archivo la primera vez y, en las siguientes ejecuciones, compara la salida nueva con esa “foto” guardada.
Con insta (en inglés; cargo add --dev insta), la prueba ocupa una sola línea:
pub fn receipt(items: &[Item]) -> String { let mut out = String::new(); for i in items { let line = format_price(i.price_cents * i.qty); out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line)); } out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items)))); out}
#[test]fn receipt_layout() { insta::assert_snapshot!(receipt(&sample_cart()));}La primera ejecución falla a propósito y escribe un archivo .snap.new para que lo revises. Cuando lo aceptas (con cargo insta review de la herramienta cargo-insta, o renombrando el archivo a .snap), queda guardado en src/snapshots/:
---source: src/lib.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00Más adelante, si alguien cambia "TOTAL" por "Total", la prueba falla con un diff claro:
-old snapshot+new results────────────┬─────────────────────────────────────────────────────────────────── 1 1 │ coffee x2 9.00 2 2 │ bagel x1 3.00 3 │-TOTAL 12.00 3 │+Total 12.00────────────┴───────────────────────────────────────────────────────────────────To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`Si el cambio era intencionado, aceptas el snapshot nuevo. Si no, acabas de encontrar un bug. Los snapshots son geniales para comprobar que “esto no cambie por accidente”, pero no te dicen que la salida sea correcta. Lee con calma el primer snapshot antes de aceptarlo.
Benchmarks con criterion
Sección titulada «Benchmarks con criterion»Si quieres saber si un código es rápido, mídelo. Las suposiciones fallan más a menudo de lo que crees. Medir el tiempo dentro de un #[test] tampoco funciona bien: las pruebas se compilan sin optimizaciones, y una sola ejecución tiene demasiado ruido.
criterion (en inglés) es la herramienta habitual en Rust estable. Ejecuta tu código muchas veces, hace la estadística por ti y recuerda el último resultado para decirte si vas más rápido o más lento. Añádelo con cargo add --dev criterion, luego indícale a Cargo cuál es el archivo del benchmark y desactiva el harness integrado:
[[bench]]name = "stock"harness = falseEste benchmark responde a una pregunta: para saber si un código de producto está en stock, ¿es más rápido un Vec o un HashSet con 1000 códigos?
use std::collections::HashSet;use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) { // 1000 códigos de producto, como "SKU-0", "SKU-1", ... let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect(); let code_set: HashSet<String> = codes.iter().cloned().collect(); let wanted = String::from("SKU-999"); // el peor caso para un Vec: el último
c.bench_function("vec contains", |b| { b.iter(|| codes.contains(black_box(&wanted))) }); c.bench_function("hashset contains", |b| { b.iter(|| code_set.contains(black_box(&wanted))) });}
criterion_group!(benches, stock_lookup);criterion_main!(benches);b.iter(...) es el código que se mide. std::hint::black_box le esconde un valor al optimizador. Sin él, el compilador podría darse cuenta de que la respuesta nunca cambia, calcularla una sola vez, y estarías midiendo nada.
Ejecútalo con cargo bench, que siempre compila en modo optimizado (release). En tu ordenador los números serán distintos:
vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) low mild 1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]Found 8 outliers among 100 measurements (8.00%) 4 (4.00%) high mild 4 (4.00%) high severeCómo leerlo: los tres tiempos forman un rango. El del medio es la mejor estimación, y los de los extremos dicen “el valor real casi seguro está entre estos dos”. Aquí el HashSet tarda unos 19 nanosegundos y el Vec unos 2,4 microsegundos, así que el conjunto es más de 100 veces más rápido en este caso. Los “outliers” (valores atípicos) son unas pocas ejecuciones inusualmente lentas o rápidas, a menudo por culpa de otros programas en tu ordenador. Que haya unos pocos es normal.
Vuelve a ejecutarlo después de un cambio, y criterion lo compara con la ejecución anterior:
vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs] change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05) No change in performance detected.El cambio es de menos del 1 % y podría ser solo ruido, así que criterion dice “No change” (sin cambios). También genera un informe HTML en target/criterion/. Para saber cuándo (y cuándo no) preocuparte por la velocidad, mira Rendimiento.
En código antiguo quizá veas funciones con #[bench]. Usan el test::Bencher integrado, que solo funciona con el compilador nightly:
#![feature(test)] // solo en nightlyextern crate test;
#[bench]fn bench_total(b: &mut test::Bencher) { b.iter(|| (1..=1000u64).sum::<u64>());}En Rust estable, usa criterion.
Cobertura e integración continua (CI)
Sección titulada «Cobertura e integración continua (CI)»Una vez que tienes pruebas, hay dos herramientas más que ayudan. Se instalan con cargo install, como las demás herramientas de Herramientas de Rust.
cargo-nextest es un ejecutor de pruebas más rápido. Ejecuta cada prueba en su propio proceso, muestra una salida más clara y puede reintentar las pruebas inestables (flaky). Algo que debes saber: no ejecuta los doc tests, así que ejecuta también cargo test --doc.
cargo install cargo-nextest --lockedcargo nextest runLa cobertura (coverage) te dice qué líneas ejecutaron de verdad tus pruebas. cargo-llvm-cov usa el soporte de cobertura integrado en el compilador y funciona en Linux, macOS y Windows:
cargo install cargo-llvm-covcargo llvm-cov # tabla resumen en la terminalcargo llvm-cov --html # un informe navegable en target/llvm-cov/htmlcargo-tarpaulin es una alternativa más antigua y popular. Funciona mejor en Linux y se usa mucho en CI:
cargo install cargo-tarpaulincargo tarpaulin --out HtmlTrata la cobertura como un mapa, no como una nota. Te muestra el código que ninguna prueba toca. No te dice si tus asserts comprueban lo correcto. Un 80 % de cobertura con buenos asserts es mejor que un 100 % con asserts débiles.
En CI (GitHub Actions, GitLab, etc.), un buen punto de partida son los mismos tres comandos que ejecutas en tu equipo, para que un fallo signifique lo mismo en todas partes:
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo testErrores comunes
Sección titulada «Errores comunes»Pruebas de integración en un crate que solo es binario. Si tu proyecto solo tiene src/main.rs, los archivos de tests/ no pueden importar nada de él:
error[E0432]: unresolved import `todo` --> tests/tasks.rs:1:5 |1 | use todo::add_task; | ^^^^ use of unresolved module or unlinked crate `todo`El compilador dice que no encuentra el módulo o crate todo. Solución: mueve la lógica a src/lib.rs y deja main.rs pequeño. Así main.rs llama a la biblioteca, y tus pruebas también. Además, el código se vuelve más fácil de reutilizar.
Poner los helpers compartidos en tests/common.rs. Cada archivo .rs que está directamente dentro de tests/ se trata como un archivo de pruebas propio, así que aparece una sección extra inútil en la salida:
Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 testsSolución: usa tests/common/mod.rs, y mod common; en cada archivo de pruebas que lo necesite.
Crates de pruebas en [dependencies]. proptest, mockall, insta y criterion van en [dev-dependencies] (usa cargo add --dev). Si no, todos los usuarios de tu crate tendrán que descargarlos y compilarlos.
Pruebas que comparten estado. Las pruebas se ejecutan en paralelo. Si dos pruebas escriben el mismo archivo o cambian la misma variable de entorno, fallarán al azar. Dale a cada prueba su propia carpeta temporal (el crate tempfile ayuda) o, como último recurso, ejecútalas con --test-threads=1.
Hacer benchmarks sin black_box, o dentro de una prueba. El optimizador puede eliminar trabajo cuyo resultado no se usa, y medir el tiempo en un #[test] en modo debug te dice poco. Usa criterion con black_box.
Práctica
Sección titulada «Práctica»1. Un saludo que se puede probar
Sección titulada «1. Un saludo que se puede probar»Esta función no se puede probar de forma fiable, porque siempre le pregunta la hora al reloj real:
fn greeting() -> &'static str { let hour = current_hour(); // lee la hora del sistema match hour { 5..=11 => "Buenos días", 12..=17 => "Buenas tardes", _ => "Buenas noches", }}Reescríbela para que reciba un trait Clock con un método, fn hour(&self) -> u64. Escribe un FixedClock que siempre devuelva la misma hora y prueba los límites: 11 y 12, 17 y 18. Pista: basta con struct FixedClock(u64);. La app real pasaría un SystemClock que lee la hora.
Ver solución
trait Clock { fn hour(&self) -> u64; // de 0 a 23}
// Un reloj que siempre marca la misma hora. Perfecto para pruebas.struct FixedClock(u64);
impl Clock for FixedClock { fn hour(&self) -> u64 { self.0 }}
fn greeting(clock: &impl Clock) -> &'static str { match clock.hour() { 5..=11 => "Buenos días", 12..=17 => "Buenas tardes", _ => "Buenas noches", }}
fn main() { for hour in [8, 12, 21] { println!("{hour}:00 -> {}", greeting(&FixedClock(hour))); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn morning_ends_at_noon() { assert_eq!(greeting(&FixedClock(11)), "Buenos días"); assert_eq!(greeting(&FixedClock(12)), "Buenas tardes"); }
#[test] fn evening_starts_at_six() { assert_eq!(greeting(&FixedClock(17)), "Buenas tardes"); assert_eq!(greeting(&FixedClock(18)), "Buenas noches"); }
#[test] fn very_early_is_still_evening() { assert_eq!(greeting(&FixedClock(3)), "Buenas noches"); }}Salida:
8:00 -> Buenos días12:00 -> Buenas tardes21:00 -> Buenas nochesgreeting ya no decide de dónde sale la hora: lo decide quien la llama. Ese único cambio hace que puedas probar cualquier hora en milisegundos. Elige Test en el menú del Playground y pasan las 3 pruebas.
2. Encuentra la regla y deja que proptest la compruebe
Sección titulada «2. Encuentra la regla y deja que proptest la compruebe»Aquí tienes una función que convierte una etiqueta de blog como " Web Dev " en "web-dev":
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}Escribe dos tests de propiedades con proptest: normalizar una etiqueta que ya está normalizada no cambia nada, y el resultado nunca contiene espacios en blanco. Pista: la estrategia ".*" genera cadenas de texto aleatorias (es una expresión regular). Para “sin espacios en blanco”, sirve prop_assert!(!s.contains(char::is_whitespace)).
Ver solución
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}
fn main() { for tag in ["Rust", " Web Dev ", "GAME dev"] { println!("{tag:?} -> {:?}", normalize_tag(tag)); }}
#[cfg(test)]mod tests { use super::*; use proptest::prelude::*;
proptest! { #[test] fn normalizing_twice_changes_nothing(tag in ".*") { let once = normalize_tag(&tag); prop_assert_eq!(normalize_tag(&once), once); }
#[test] fn result_has_no_whitespace(tag in ".*") { let normalized = normalize_tag(&tag); prop_assert!(!normalized.contains(char::is_whitespace)); } }}Salida:
"Rust" -> "rust"" Web Dev " -> "web-dev""GAME dev" -> "game-dev"Run funciona en el Playground, pero Test no, porque el Playground no tiene proptest. En tu propio proyecto, añádelo con cargo add --dev proptest y ejecuta cargo test:
running 2 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02sLas cadenas aleatorias incluyen tabuladores, saltos de línea y letras de otros idiomas, cosas que probablemente nunca escribirías en una prueba hecha a mano. “Hacerlo dos veces es lo mismo que hacerlo una” es una propiedad que vale la pena comprobar en cualquier función de limpieza.
Resumen
Sección titulada «Resumen»- Las pruebas unitarias van en
#[cfg(test)] mod testsjunto al código; las de integración, entests/(con los helpers compartidos entests/common/mod.rs), y los doc tests, en los comentarios///. - Usa
#[ignore]para las pruebas lentas y ejecútalas concargo test -- --ignored; filtra por nombre concargo test algun_nombrey mira la salida deprintln!con-- --nocapture. - Para probar código que habla con el mundo exterior, pon esa dependencia detrás de un trait y pasa un fake en las pruebas. Usa mockall cuando escribir fakes a mano se vuelva pesado.
- Los tests de propiedades (proptest) comprueban una regla con cientos de entradas aleatorias y reducen un fallo al ejemplo más simple. Los tests de snapshot (insta) detectan cambios accidentales en salidas grandes.
- Mide la velocidad con criterion y
cargo bench, y envuelve las entradas enstd::hint::black_box.#[bench]necesita nightly. - cargo-nextest ejecuta las pruebas más rápido; cargo-llvm-cov y cargo-tarpaulin muestran qué líneas cubren tus pruebas.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. Varios archivos de tests/ necesitan la misma función auxiliar. ¿Dónde debería ir?
2. ¿Qué hace cargo test -- --ignored?
3. Una propiedad de proptest falla. ¿Qué muestra proptest como entrada mínima que falla?
4. ¿Por qué los benchmarks de criterion envuelven los valores en std::hint::black_box?
Puntuación: 0 / 4
Para leer más
Sección titulada «Para leer más»- Escribir pruebas automatizadas — El libro de Rust (en inglés): el capítulo oficial sobre cómo escribir, ejecutar y organizar pruebas unitarias y de integración.
- Testing — Rust by Example (en inglés): ejemplos cortos de pruebas unitarias, doc tests, pruebas de integración y dev-dependencies.
- The proptest book (en inglés): estrategias, shrinking, configuración y cómo se compara proptest con quickcheck.
- Documentación de mockall (en inglés):
#[automock], expectativas, comparación de argumentos, valores de retorno y mocks de structs. - Guía de usuario de Criterion.rs (en inglés): primeros pasos, cómo leer la salida, comparar funciones y los informes HTML.