Tests de integración: probar el crate desde fuera
La carpeta tests/ contiene pruebas que compilan como crates independientes y enlazan con el tuyo como si fueran un usuario más. Solo ven la API pública, no lo privado: eso es exactamente lo que quieres verificar. Cada fichero es su propio ejecutable, y ahí nace la necesidad de separar lib.rs de main.rs.
Los tests unitarios miran hacia dentro: conocen lo privado y verifican tripas. Pero hay una pregunta que solo puede responderse desde fuera: ¿funciona tu crate para quien lo usa? Un usuario no ve tus funciones privadas ni tu mod tests; ve la superficie pública, y sobre ella construye. Para reproducir esa mirada externa, Rust reserva un lugar especial: la carpeta tests/ en la raíz del proyecto. Cada fichero que colocas ahí se compila como un crate independiente, separado del tuyo, que enlaza con tu librería exactamente igual que lo haría un tercero: importando el nombre del crate y usando solo lo que declaraste pub. Es testing de caja negra impuesto por la propia arquitectura de compilación. Y esa arquitectura tiene una consecuencia que reordena cómo estructuras un proyecto entero: obliga a separar la lógica en lib.rs del punto de entrada en main.rs.
- Ubicar pruebas en
tests/y comprender que cada fichero compila como un crate separado. - Ejercitar únicamente la API pública del crate, importándolo por su nombre como un usuario externo.
- Compartir ayudantes entre pruebas con el patrón
tests/common/mod.rssin crear un test espurio. - Separar
lib.rsdemain.rspara poder probar la lógica de un binario desde fuera.
La carpeta tests/ y el modelo de compilación
Junto a src/, en la raíz del proyecto, Cargo reconoce una carpeta tests/ con estatus especial. Si tu crate se llama calculadora y su src/lib.rs expone una función pub fn suma, un fichero tests/aritmetica.rs la usa así:
// tests/aritmetica.rs
use calculadora::suma;
#[test]
fn suma_desde_fuera() {
assert_eq!(suma(2, 3), 5);
}
Fíjate en lo que no hay: ni #[cfg(test)], ni mod tests, ni use super::*. No hacen falta. Cada fichero de tests/ se compila solo durante cargo test —nunca en un build normal—, así que envolverlo en #[cfg(test)] sería redundante. Y como es un crate aparte, no tiene un super: importa calculadora por su nombre, igual que importaría cualquier dependencia externa. Aquí suma es visible únicamente porque es pub; una función privada de tu crate sería, desde este lado, invisible. La restricción es el objetivo: estas pruebas verifican el contrato que tus usuarios realmente ven.
El modelo de compilación es literal: cada fichero de primer nivel en tests/ se convierte en un crate y en un binario de prueba distinto. tests/aritmetica.rs y tests/trigonometria.rs son dos ejecutables independientes que Cargo lanza por separado. Puedes dirigir uno concreto con cargo test --test aritmetica. Compilar muchos ficheros pequeños tiene un coste de enlazado; por eso algunos proyectos agrupan sus pruebas de integración en pocos ficheros grandes.
Unitarias: caja blanca
Viven en src, junto al código, en #[cfg(test)] mod tests. Ven lo privado. Verifican detalles internos de implementación.
Integración: caja negra
Viven en tests/ y compilan como crates aparte. Solo ven la API pub. Verifican el contrato que percibe el usuario.
Doc tests: el ejemplo vivo
Viven en los comentarios ///. Ejecutan la documentación a través de la API pública. Verifican que los ejemplos no mienten.
Ayudantes compartidos: el patrón common/mod.rs
Tarde o temprano varias pruebas necesitan el mismo andamiaje: construir un cliente, sembrar datos, fabricar un fichero temporal. La tentación es un tests/common.rs, pero eso falla de una forma sutil e instructiva: como todo fichero de primer nivel en tests/ es un crate de prueba, tests/common.rs se compilaría por su cuenta y aparecería en la salida como una suite vacía —cero pruebas—, ensuciando el informe.
La solución idiomática aprovecha la regla de módulos: un fichero llamado mod.rs dentro de un subdirectorio no se trata como crate de prueba de primer nivel, sino como un módulo.
// tests/common/mod.rs
pub fn cliente_de_prueba() -> Cliente {
Cliente::nuevo("http://localhost:0")
}
// tests/api.rs
mod common;
#[test]
fn saluda() {
let c = common::cliente_de_prueba();
assert_eq!(c.saludo(), "hola");
}
Al vivir en tests/common/mod.rs, el ayudante se declara con mod common; desde cada fichero de prueba que lo necesite, se comparte sin duplicación y no genera una suite fantasma. Es el mismo sistema de módulos de siempre, aplicado a la peculiar topología de tests/.
lib.rs frente a main.rs: la arquitectura que el testing impone
Aquí surge una fricción reveladora. Un crate binario puro —solo src/main.rs— no puede probarse desde tests/, porque no hay una librería con la que enlazar: main no es una API pública importable. La respuesta idiomática, y una de las mejores costumbres que enseña el testing de Rust, es partir el proyecto en dos:
// src/lib.rs (toda la logica, publica y comprobable)
pub fn ejecutar(args: &[String]) -> Result<String, String> {
// ...
Ok(format!("procesados {} argumentos", args.len()))
}
// src/main.rs (una cascara minima)
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match miapp::ejecutar(&args) {
Ok(salida) => println!("{salida}"),
Err(e) => { eprintln!("error: {e}"); std::process::exit(1); }
}
}
Con ejecutar viviendo en lib.rs, un fichero tests/cli.rs puede importarla y verificar la lógica de negocio sin arrancar un proceso. El main queda reducido a lo que ningún test necesita cubrir: leer argumentos, imprimir, elegir el código de salida. Para las pocas cosas que solo se manifiestan ejecutando el binario de verdad —códigos de salida, stderr, comportamiento de extremo a extremo— existen crates como assert_cmd, que lanzan el ejecutable compilado y examinan su resultado.
flowchart TD U[Tests unitarios en src junto al codigo] --> PR[Ven lo privado caja blanca] I[Tests de integracion en la carpeta tests] --> CR[Cada fichero es un crate separado] CR --> PU[Solo ven la API publica caja negra] CR --> N[Necesitan un lib.rs importable] N --> S[Separar la logica en lib.rs de la cascara main.rs] style U fill:#89b4fa,color:#11111b style I fill:#cba6f7,color:#11111b style PU fill:#a6e3a1,color:#11111b style S fill:#f9e2af,color:#11111b
Elegir el nivel: pocas de integración, muchas unitarias
Con tres niveles disponibles surge la pregunta de proporción. La heurística clásica —la pirámide de pruebas— sugiere una base ancha de pruebas unitarias, rápidas y numerosas, una franja media de pruebas de integración y una cúspide estrecha de pruebas de extremo a extremo, lentas y frágiles. En Rust esa pirámide se apoya sobre un cimiento que otros lenguajes no tienen: el compilador ya demostró lo que en otros sitios exige pruebas. No escribes tests para comprobar que un valor no es nulo, que un tipo encaja o que no hay uso tras liberación: esas clases enteras de fallo son errores de compilación. El testing, liberado de verificar lo que el sistema de tipos garantiza, se concentra en lo que ningún tipo captura: la lógica.
La consecuencia práctica es un reparto nítido. La mayoría de tus afirmaciones vive en pruebas unitarias junto al código, donde compilan y corren en un pestañeo y pueden espiar lo privado. Reservas tests/ para lo que solo tiene sentido desde fuera: que los módulos encajan entre sí, que la API pública es usable, que el flujo completo entrega lo prometido. Y como cada fichero de tests/ paga un coste de enlazado, agrupas las pruebas afines en pocos ficheros grandes en vez de dispersarlas en muchos pequeños. cargo test --lib corre solo las unitarias; cargo test --test <nombre>, una suite de integración concreta: dos velocidades para dos momentos del bucle de desarrollo.
Podría parecer una carencia que las pruebas de tests/ no alcancen lo privado, y sin embargo es su virtud esencial. Un test unitario verifica cómo está hecho algo; un test de integración verifica qué promete tu crate al mundo, y esa distinción es la línea entre implementación y contrato. Cuando escribes desde tests/, dejas de ser el autor omnisciente y te conviertes en un usuario cualquiera que solo dispone de lo que declaraste pub. Esa amnesia deliberada es diagnóstica por partida doble. Primero, prueba que tu superficie pública es suficiente: si para verificar un comportamiento tienes que espiar un campo privado, es que ese comportamiento no es realmente observable, y quizá tampoco lo sea para tus usuarios. Segundo, y más profundo, blinda tus refactorizaciones: mientras la API pública se mantenga, puedes reorganizar las tripas por completo y estas pruebas seguirán verdes, porque nunca dependieron de las tripas. Los tests unitarios, atados a lo interno, se rompen cuando cambias lo interno —a veces con razón, a veces solo por rozar detalles que a nadie importan—; los de integración se rompen exactamente cuando rompes una promesa. Y observa cómo esta filosofía reordena la arquitectura sin pedir permiso: la imposibilidad de probar un main empuja a extraer la lógica a lib.rs, que resulta ser también la mejor estructura por razones que nada tienen que ver con el testing —reutilización, documentación, límites nítidos—. Rust no te obliga a testear bien; te tiende una topología donde testear bien y diseñar bien son, casualmente, el mismo gesto. La carpeta tests/ no comprueba tu código: comprueba tu palabra.
- Crea un crate con
src/lib.rsque exponga una funciónpuby una privada; añadetests/publica.rse intenta usar ambas. Explica el error de compilación al tocar la privada. - Divide un proyecto binario en
lib.rs(lógica) ymain.rs(cáscara); escribe una prueba de integración que ejercite la lógica sin arrancar el proceso. - Provoca a propósito la suite fantasma poniendo un
tests/common.rs, observa la línea de cero pruebas en el informe, y arréglalo moviéndolo atests/common/mod.rs. - Con dos ficheros de prueba distintos, usa
cargo test --test <nombre>para ejecutar solo uno; razona por qué es un binario aparte. - Añade
assert_cmdcomo[dev-dependency]y escribe una prueba que verifique el código de salida y elstderrdel binario real; contrasta qué cubre esto que la prueba de la lógica no podía cubrir.