Organización de los Tests

Como se mencionó al comienzo del capítulo, el testing es una disciplina, y diferentes personas usan diferentes terminologías y organización. La comunidad de Rust piensa en los tests en términos de dos categorías principales: tests de unidad e integración. Los tests de unidad son pequeños y más enfocados, probando un módulo a la vez en aislamiento, y pueden probar interfaces privadas. Los tests de integración son completamente externos a tu biblioteca y usan tu código de la misma manera que cualquier otro código externo, usando solo la interfaz pública y potencialmente ejercitando múltiples módulos por test.

Escribir ambos tipos de tests es importante para asegurar que las piezas de tu biblioteca están haciendo lo que esperas, separada y conjuntamente.

Tests Unitarios

El propósito de los tests unitarios es probar cada unidad de código en aislamiento del resto del código para rápidamente identificar donde el código está y no está funcionando como se espera. Pondrás los tests unitarios en el directorio src en cada archivo con el código que están testeando. La convención es crear un módulo llamado tests en cada archivo para contener las funciones de test y anotar el módulo con cfg(test).

El módulo de tests y #[cfg(test)]

La anotación #[cfg(test)] en el módulo de tests le dice a Rust que compile y ejecute el código de test solo cuando ejecutas cargo test, no cuando ejecutas cargo build. Esto ahorra tiempo de compilación cuando solo quieres compilar la biblioteca y ahorra espacio en el resultado compilado porque los tests no están incluidos. Verás que porque los tests de integración van en un directorio diferente, no necesitan la anotación #[cfg(test)]. Sin embargo, porque los tests unitarios van en los mismos archivos que el código, usarás #[cfg(test)] para especificar que no deberían ser incluidos en el resultado compilado.

Recuerda que cuando generamos el nuevo proyecto adder en la primera sección de este capítulo, Cargo generó este código para nosotros:

Filename: src/lib.rs

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

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

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Este código es el módulo de tests generado automáticamente. El atributo cfg significa configuración y le dice a Rust que el siguiente item debería ser incluido solo si una cierta opción de configuración está presente. En este caso, la opción de configuración es test, la cual es provista por Rust para compilar y ejecutar tests. Al usar el atributo cfg, Cargo compila nuestro código de test solo si activamente ejecutamos los tests con cargo test. Esto incluye cualquier función auxiliar que pueda estar dentro de este módulo, en adición a las funciones anotadas con #[test].

Testeando Funciones Privadas

Hay debate dentro de la comunidad de testing sobre si las funciones privadas deberían ser testeables directamente, y otros lenguajes hacen difícil o imposible testear funciones privadas. Independientemente de la ideología de testing a la que te adhieras, las reglas de privacidad de Rust te permiten testear funciones privadas. Considera el código en el Listado 11-12 con la función privada internal_adder.

Filename: src/lib.rs

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

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

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

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

Listing 11-12: Testeando una función privada

Nota que la función internal_adder no está marcada como pub. Los tests son solo código Rust, y el módulo tests es solo otro módulo. Como discutimos en la sección “Paths for Referring to an Item in the Module Tree”, items en módulos hijos pueden usar los items en sus ancestros. En este test, traemos todos los items del padre del módulo tests al alcance con use super::*, y entonces el test puede llamar a internal_adder. Si no piensas que las funciones privadas deberían ser testeables, no hay nada en Rust que te obligue a hacerlo.

Tests de Integración

En Rust, los tests de integración son completamente externos a tu biblioteca. Usan tu biblioteca de la misma manera que cualquier otro código externo, lo cual significa que solo pueden llamar a funciones que son parte de la API pública. Su propósito es probar si muchas partes de tu biblioteca funcionan correctamente juntas. Unidades de código que funcionan correctamente por su cuenta podrían tener problemas cuando se integran, así que la cobertura de tests del código integrado es importante también. Para crear tests de integración, primero necesitas un directorio tests.

El directorio tests

Se crea un directorio llamado tests en el nivel superior del directorio de nuestro proyecto, al lado de src. Cargo sabe buscar archivos de test de integración en este directorio. Podemos crear tantos archivos de test como queramos en este directorio, y Cargo compilará cada archivo como un crate individual.

Creemos un test de integración. Con el código en el Listado 11-12 aún en el archivo src/lib.rs, crea un directorio tests y crea un nuevo archivo llamado tests/integration_test.rs. Tu estructura de directorios debería verse así:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

Introducimos el código en el Listado 11-13 en el archivo tests/integration_test.rs:

Filename: tests/integration_test.rs

use adder::add_two;

#[test]
fn it_adds_two() {
    assert_eq!(4, add_two(2));
}

Listing 11-13: Un test de integración de una función en el crate adder

Cada archivo en el directorio tests es un crate separado, así que necesitamos importar nuestra biblioteca en el scope de cada crate de test. Por esa razón, agregamos use adder::add_two al inicio del código, lo cual no necesitamos en los tests unitarios.

No es necesario anotar ningún código en tests/integration_test.rs con #[cfg(test)]. Cargo trata al directorio tests de manera especial y compila los archivos en este directorio solo cuando ejecutamos cargo test. Ejecuta cargo test ahora:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Las tres secciones de output incluyen los tests unitarios, el test de integración y los tests de documentación. Nota que si algún test en una sección falla, las siguientes secciones no serán ejecutadas. Por ejemplo, si falla un test unitario, no habrá ningún output para los tests de integración y de documentación porque esos tests solo serán ejecutados si todos los tests unitarios pasan.

La primera sección es para los tests unitarios es la misma que hemos visto: una línea para cada test unitario (uno llamado internal que agregamos en el Listado 11-12) y luego una línea de resumen para los tests unitarios.

Los tests de integración comienzan con la línea Running tests/integration_test.rs. Luego, hay una línea para cada función de test en ese test de integración y una línea de resumen para los tests de integración justo antes de que comience la sección Doc-tests adder.

Cada archivo de test de integración tiene su propia sección, así que si agregamos más archivos en el directorio tests, habrá más secciones de tests de integración.

Todavía podemos ejecutar una función de test de integración en particular especificando el nombre de la función de test como argumento de cargo test. Para ejecutar todos los tests en un archivo de test de integración en particular, usa el argumento --test de cargo test seguido del nombre del archivo:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Este comando ejecuta solo los tests en el archivo tests/integration_test.rs.

Submódulos en Tests de Integración

En la medida en que se agregan más tests de integración, es posible que quieras crear más archivos en el directorio tests para ayudar a organizarlas; por ejemplo, puedes agrupar las funciones de test por la funcionalidad que están probando. Como se mencionó anteriormente, cada archivo en el directorio tests es compilado como un crate separado, lo cual es útil para crear scopes separados para imitar más de cerca la manera en que los usuarios finales usarán tu crate. Sin embargo, esto significa que los archivos en el directorio tests no comparten el mismo comportamiento que los archivos en src, como aprendiste en el Capítulo 7 sobre cómo separar el código en módulos y archivos.

La diferencia en el comportamiento de los archivos en src y tests es más notable cuando tienes un conjunto de funciones de ayuda para usar en múltiples archivos de test de integración y tratas de seguir los pasos en la sección

del Capítulo 7 para extraerlas

en un módulo común. Por ejemplo, si creamos tests/common.rs y colocamos una función llamada setup en él, podemos agregar algo de código a setup que queremos llamar desde múltiples funciones de test en múltiples archivos de test:

Filename: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

Cuando volvemos a ejecutar los tests, veremos una sección en el output de los tests para el archivo common.rs, aunque este archivo no contiene ninguna función de test ni hemos llamada a la función setup desde ningún lado:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Tener common apareciendo en los resultados de los tests con running 0 tests mostrado para él no es lo que queríamos. Solo queríamos compartir algo de código con los otros archivos de test de integración.

Para evitar que common aparezca en el output de los tests, en lugar de crear tests/common.rs, crearemos tests/common/mod.rs. El directorio del proyecto ahora se ve así:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

Esta es la convención de nomenclatura anterior que Rust también entiende y que mencionamos en la sección “Rutas de Archivos Alternativas” del Capítulo 7. Nombrar el archivo de esta manera le dice a Rust que no trate al módulo common como un archivo de test de integración. Cuando movemos el código de la función setup a tests/common/mod.rs y borramos el archivo tests/common.rs, la sección en el output de los tests ya no aparecerá. Los archivos en subdirectorios del directorio tests no son compilados como crates separados ni tienen secciones en el output de los tests.

Después de haber creado tests/common/mod.rs, podemos usarlo desde cualquier archivo de test de integración como un módulo. Aquí hay un ejemplo de llamar a la función setup desde el test it_adds_two en tests/integration_test.rs:

Filename: tests/integration_test.rs

use adder;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, adder::add_two(2));
}

Nota que la declaración mod common; es la misma que la declaración de módulo que demostramos en el Listado 7-21. Luego, en la función de test, podemos llamar a la función common::setup().

Tests de Integración para Crates Binarios

Si nuestro proyecto es un crate binario que solo contiene un archivo src/main.rs y no tiene un archivo src/lib.rs, no podemos crear tests de integración en el directorio tests y traer funciones definidas en el archivo src/main.rs al scope con una declaración use. Solo los crates de librería exponen funciones que otros crates pueden usar; los crates binarios están destinados a ser ejecutados por sí mismos.

Esta es una de las razones por las que los proyectos Rust que proveen un binario tienen un archivo src/main.rs que llama a la lógica que vive en el archivo src/lib.rs. Usando esa estructura, los tests de integración pueden probar el crate de la librería con use para hacer que la funcionalidad importante esté disponible. Si la funcionalidad importante funciona, la pequeña cantidad de código en el archivo src/main.rs también funcionará, y ese pequeño código no necesita ser testeado.

Resumen

Las características de testing de Rust proveen una manera de especificar cómo el código debería funcionar para asegurarse de que continúe funcionando como esperas, incluso mientras haces cambios. Los tests unitarios ejercitan diferentes partes de una librería por separado y pueden testear detalles de implementación privados. Los tests de integración chequean que muchas partes de la librería funcionen juntas correctamente, y usan la API pública de la librería para testear el código de la misma manera que el código externo lo usará. Aunque el sistema de tipos y las reglas de ownership de Rust ayudan a prevenir algunos tipos de bugs, los tests son todavía importantes para reducir bugs de lógica que tienen que ver con cómo se espera que tu código se comporte.

¡Combinemos el conocimiento que aprendiste en este capítulo y en capítulos anteriores para trabajar en un proyecto!