Rusty Full Stack

El blog para los amantes de Rust, Ionic y Vuejs

Jaime Blandón
Jaime Blandón Desarrollador de software desde 2009, entusiasta de Rust, Vuejs y Ionic!. Fundador de este blog, espero que las publicaciones te sean de utilidad y si tienes comentarios para mejorar, son bienvenidos.

Pruebas Unitarias Con Rust - Módulos de Prueba

Hola!!!, esta es la primera publicación de otra de nuestras mini-series 😉, ahora vamos a aprender sobre cómo construir pruebas unitarias con Rust.

Saber construir unit tests es una habilidad importante par los programadores profesionales porque nos ayudan a validar que nuestro código cumpla con las reglas de negocios establecidas, o al menos eso deberían, pero cualquiera podría preguntarse "¿pero eso mismo podemos lograr probando nuestro software por nosotros mismos sin construir pruebas unitarias? 🤔"

La respuesta a la pregunta anterior es un gran SI. En general podemos probar nuestro código de varias formas, incluso la menos sofisticada que consiste en probar y probar y probar hasta que el usuario final se ponga contento, sin embargo esto tiene ciertas desventajas:

  • Cuando el proyecto o software empieza a crecer (al igual que el código o la infraestructura), al hacer una modificación puede causar una bug inesperado.
  • Si hay varias personas trabajando en el proyecto, las pruebas unitarias facilitan a todos los programadores a comprender o al menos a asegurarse de forma automática la no introducción de nuevos bugs.
  • No tenemos una "red de seguridad" que nos garantiza que las reglas de negocio no han sido alteradas en algún commit a nuestro código o alguna actualización.
  • No es posible, de forma sencilla, el tener métricas o cifras que nos indiquen realmente la cantidad del código que hemos probado o si hay código que está sobrando (creanme aveces dejamos bloques de código sin ser utilizados 😱)

además de las desventajas mencionadas anteriormente, cada quien puede tener su propia opinión o listado.

Como comentaba anteriormente, el software sin unit tests, puede funcionar de forma esperada y a lo mejor si es un proyecto pequeño ni siquiera se pueda ver que sea algo relevante, pero te aconsejo que aprendamos y hagamos como costumbre el siempre construirlo y espero poderte mostrar con este post algún ejemplo de su utilidad.

¿Qué son las pruebas unitarias o los Unit Tests?

Hay muchas definiciones sobre los unit tests, pero en la práctica no son más que pequeños scripts, módulos, programas, etc. que sirvan para validar la funcionalidad de nuestro código. Hay mucha información y documentación sobre las buenas prácticas y características que deben cumplir como por ejemplo probar "pequeñas" funcionalidades, probar que fallen y tengan éxito, etc.

También existen diversas opiniones sobre en qué momento escribir las pruebas unitarias, existen tendencias como Test Driven Development (TDD) el cual sugiere la creación de las pruebas unitarias incluso antes de escribir cualquier función o método del código del proyecto, sin embargo, cada compañía o proyecto puede definir la estrategia a seguir para la construcción de las pruebas.

No es una intención de esta publicación ahondar mucho en la filosofía o metodologías detrás de la construcción de las pruebas unitarias por lo que te invito a puedas documentarte leyendo libros, blogs o videos sobre las distintas metodologías, porque incluso en más de alguna entrevista de trabajo pueden llegarte a preguntar cual es la estrategia que prefieres o sigues y claramente te dará mucha ventaja el conocerlas.

¿Cómo escribimos pruebas unitarias con Rust?

Como había comentado anteriormente, las pruebas unitarias también son bloques de código, cada lenguaje de programación tiene su propio módulo o librería para escribirlas y Rust no es la excepción, sin embargo, Rust tiene algunas convenciones.

Puedes leer más sobre las convenciones dando click acá.

Una de las convenciones es que Rust hace una diferencia entre pruebas unitarias (unit test) y pruebas de integración (integration tests).

La diferencia entre uno u otro tipo depende de muchas fuentes o literatura, pero en el caso de Rust las vamos a diferencia de esta forma:

  • Las pruebas unitarias son aquellas que sirven para probar bloques de código en aislamiento (el tamaño en realidad es relativo a tu código) y a diferencia de otros lenguajes, en Rust se espera que las pruebas unitarias se encuentren en cada archivo de nuestros módulos o librerías, si quieres aprender más sobre módulos y librerías, puedes ver nuestra mini serie "Organizar Código en Rust" 🥳
  • Las pruebas de integración son toda aquellas que son externas a nuestra librería y por consiguiente, sirven para probar que todos los bloques de código funcionen en armonía como esperan las reglas de negocio, para las pruebas de integración, vamos a crear una carpeta llamada tests, pero sobre las pruebas de integración vamos a aprender en otra publicación 🥸

Para escribir las pruebas unitarias vamos a hacer una librería y la probaremos, nuestra librería o ejemplo tratará sobre:

Vamos a escribir una librería que simulará la inscripción o matrícula a una materia, para poder inscribirse a una materia, será necesario contar con los créditos suficientes. Para calcular los créditos vamos a multiplicar la nota final de un estudiante en esa materia por un factor de créditos. A lo mejor se escucha extraño pero empecemos a hacer el código del módulo y verás a lo que me refiero 😅

Comencemos creando un nuevo proyecto de tipo librería utilizando cargo.

cargo new --lib matricula

cd matricula

(si quieres ver el código completo puedes hacerlo desde el repositorio en github dando click acá)

Si ahora vemos la estructura del proyecto, veremos dentro de la carpeta src/ el archivo lib.rs

Si abres el archivo lib.rs que ha sido creada por cargo, vas a ver el siguiente bloque de código que fue creado automáticamente.

Puedes ver que cargo, ha creado el módulo de tests de forma automática, recuerda que en la convención, es un módulo de unit tests por archivo. El bloque anterior únicamente está probando que el resultado de sumar 2 + 2 sea 4 😋

Por el momento no te preocupes mucho por entender que hace cada una de las líneas de ese bloque de código y borrémoslo para quedarnos con el archivo en blanco, ya crearemos nuestro propio módulo de pruebas.

Puesto que la idea es aprender sobre las pruebas unitarias, simplemente copia y pega el siguiente bloque de código dentro de src/lib.rs

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        Self{
            nombre,
            creditos_requeridos,
            factor_creditos
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}



        
        
    

básicamente  cada estructura tiene un método, en el caso de Materia, tiene un método llamado puede_inscribirse el cual devuelve un boolean, la idea de ese método, es verificar que si los créditos ingresados son suficientes para poder inscribirse a la materia, la definición del código es:

        
        
            
pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
   creditos_estudiante >= self.creditos_requeridos
}

        
        
    

Vamos a escribir nuestras primeras pruebas unitarias, la idea de nuestra primera prueba unitaria será comprobar que en efecto si los créditos ingresados son mayores a los créditos necesarios, entonces deberá devolver un true.

Como la convención es que las pruebas unitarias deben estar en el mismo archivo, entonces empecemos colocando el siguiente código en el archivo src/lib.rs

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        Self{
            nombre,
            creditos_requeridos,
            factor_creditos
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {


}

        
        
    

si te fijas al final del código hemos colocado el siguiente código:

        
        
            
#[cfg(test)]
mod test {


}
        
        
    

del script anterior la línea #[cfg(test)] indica un macro para configurar los tests o las pruebas unitarias, simplemente indica que el módulo "test" servirá para colocar nuestras pruebas unitarias. Sin embargo, todavía no hemos hecho ninguna prueba todavía.

incorporemos nuestra prueba prueba, le vamos a llamar:

        
        
            
puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos
        
        
    

Aunque el nombre sea largo, siempre es bueno que los nombres describan lo que la prueba unitaria quiere constatar 🧐.

ahora vamos a actualizar el src/lib.rs con el siguiente código, es importante ver lo que pondremos dentro del mod test.

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        Self{
            nombre,
            creditos_requeridos,
            factor_creditos
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

}

        
        
    

Enfoquémonos en el módulo test de nuestro archivo:

        
        
            
#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

}

        
        
    

lo primero a destacar es que dentro del módulo hemos incorporado la línea:

use super::*;

eso se debe a que el módulo de pruebas, siempre sigue siendo un módulo, por lo que todo lo que está afuera siempre es necesario indicarle al módulo que lo vamos a utilizar.

Lo siguiente a notar es que antes nuestra prueba unitaria no es más que una función en rust al cual le anteponemos el macro #[test], es importante notar que el método del módulo no retorna algún tipo de datos.

        
        
            
#[test]
fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos()
        
        
    

el código del método:

        
        
            
#[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }
        
        
    

Lo que hace es que crea una instancia de la estructura Materia en la cual le brindamos valores que nosotros controlamos, por ejemplo los créditos necesarios que hemos asignado para que alguien pueda inscribirse es de 10.0 y el factor de créditos es de 0.5

Luego creamos una variable para evaluar en nuestro caso es la línea:

let creditos_a_evaluar = 11.0;

y en la siguiente línea evaluamos nuestra función, en el cual le pasamos al método puede_inscribirse el valor a evaluar (creditos_a_evaluar) que hemos definido como 11.0. Si los créditos necesarios es de 10.0, entonces si le pasamos el 11.0 nos debería devolver un true. Para evaluar dos valores podemos utilizar la expresión:

 assert_eq!(resultado_obtenido, true);

el cual evalúa que ambos parámetros sean iguales, has muchos tipos de "assert", pero por el momento vamos a utilizar el que termina en _eq.

Ahora vamos a ejecutar nuestras pruebas, es importante mencionar que la ejecución de las pruebas unitarias lo que hace es crear un compilado de nuestro código que únicamente se utiliza para pruebas, este es otro de los efectos del macro #[test], que sirve para crear ese compilado para pruebas nada más, en caso se haga un compilado para producción, todo lo que tenga el macro #[test] no sería agregado a nuestros binarios, lo cual es super importante para no tener binarios con código que no se ejecutaría y solamente haría más grandes los ejecutables.

Para ejecutar nuestras pruebas unitarias lo hacemos con cargo ejecutando el siguiente comando.

cargo test

Al ejecutarlo deberíamos ver estos resultados:

La descripción tiene bastante información, lo que es importante revisar es que las dos primeras líneas nos indican que el compilado de pruebas fue un éxito, y luego vemos que una línea que dice:

running 1 test

esto se debe a que solamente tenemos una prueba unitaria y en la línea:

        
        
            
test test::puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos ... ok
        
        
    

indica que la prueba termino de forma exitosa, es decir que nuestra prueba unitaria cumple con lo que queremos demostrar que es que si los créditos son mayores a los requeridos, entonces sí es posible inscribirse!

¿Qué pasa si la prueba falla?

Esta es una pregunta muy importante y no solamente me quisiera hacer un código, sino, mas bien me gustaría comentarte mi punto de vista de qué significa que una prueba unitaria falle.

Cada programador tiene su propio estilo de construir pruebas unitarias, de hecho, las buenas prácticas recomiendan que a parte de probar que la prueba unitaria pase de forma exitosa, también recomiendan probar que falle para detectar si la prueba unitaria está bien construida, pero eso solamente aplica para las primeras versiones de nuestro código, sin embargo, imagina que ahora ya nuestro programa tiene varios días, semanas, meses, incluso años de utilizarse, y ahora los usuarios solicitan algún cambio en el cual nos toca modificar uno ó varios componentes relacionados con la estructura "Materia", la persona que haga el cambio puede ser la mismo que la programó desde un principio o puede ser incluso otra persona.

Al modificar el código, si la prueba unitaria falla entonces quiere decir que es posible que se introduzca un error en nuestro programa o incluso el error no necesariamente signifique un error, sino más bien nos indica que ya no cumple con las reglas de negocio que nos solicitaron y hay que realizar más adaptaciones para cumplir las expectativas o nuevas reglas de negocio, a eso me refería que las pruebas unitarias son como una "red de seguridad" ya que ellos fallen significa que hay algo que debe ajustarse antes de seguir adelante con el desarrollo del programa.

Existen algunas compañías o programadores que antes de realizar un despliegue o una revisión de código, verifican que las pruebas unitarias pasen ya sea de forma manual o de forma automática (si quieres que hagamos un ejemplo de como automatizar la ejecución de las pruebas unitarias deja un comentario al final de esta publicación 😬) para evitar desplegar versiones de nuestro código que contenga errores o incumpla con reglas de negocios esperadas.

Pruebas Unitarias que Fallan Con Rust.

Ahora si vamos a ver como fallan las pruebas unitarias en Rust, para hacerlo, intencionalmente vamos a introducir un error en nuestro código.

Modifiquemos nuestro método puede_inscribirse, el método con error se debería ver algo así:

        
        
            // Le cambiamos el signo de comparación.
pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
    creditos_estudiante < self.creditos_requeridos
}


        
        
    

el código completo en src/lib.rs quedaría de esta forma:

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        Self{
            nombre,
            creditos_requeridos,
            factor_creditos
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante < self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

}

        
        
    

Si ahora ejecutamos nuestras pruebas con:

cargo test

veremos que ahora nos mostrará que la prueba unitaria no pasa o que termina en FAILED.

cuando una prueba unitaria falla, es el equivalente a que se haya invocado el macro llamado panic!, el cual es utilizado para retornar un error, es el equivalente a una Exception en otros lenguajes de programación.

Regresemos nuestro código a la normalidad para que ahora sí nuestra prueba pase.

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        Self{
            nombre,
            creditos_requeridos,
            factor_creditos
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

}

        
        
    

¿Qué pasa si lo que queremos probar es que nuestro programa lance un error?

Aveces no solamente queremos probar que nuestro programa siga el "happy path" o los caso de éxito, aveces también queremos probar que nuestro programa lance un error pues sería el comportamiento esperado, vamos a hacer un ejemplo, vamos a incorporar a nuestra estructura Materia una método que valide que su atributo creditos_requeridos sea mayor a 0 y menor o igual a 10

Nuestro código en el src/lib.rs debe verse como el siguiente código, donde hemos modificado el constructor de la estructura Materia, agregando la validación y levantando un error (panic!) si la validación no se cumple y de una vez incorporamos al final una nueva prueba unitaria donde probaremos si el error se levanta:

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        if creditos_requeridos > 0.0 && creditos_requeridos <= 10.0 {
            Self{
                nombre,
                creditos_requeridos,
                factor_creditos
            }
        } else {
            panic!("Creditos requeridos deben ser mayor a 0.0 y menor o igual que 10.0")
        }

    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

    #[test]
    fn levanta_un_error_si_los_creditos_proporcionados_no_estan_en_el_rango_esperado() {
        let materia = Materia::new(
            String::from("matematicas"),
            -10.0,
            0.5
        );

        assert_eq!(1, 1);
    }

}

        
        
    

Si ves ahora el constructor tiene esta validación en el cual se levanta el error si los creditos_requeridos no están en el rango esperado:

        
        
            // Constructor de Materia

pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        if creditos_requeridos > 0.0 && creditos_requeridos <= 10.0 {
            Self{
                nombre,
                creditos_requeridos,
                factor_creditos
            }
        } else {
            panic!("Creditos requeridos deben ser mayor a 0.0 y menor o igual que 10.0")
        }
}

        
        
    

también la nueva prueba unitaria se mira de la siguiente forma donde a propósito se ha colocado un assert_eq!(1,1), donde por supuesto que 1 es igual a 1, pero si ejecutamos las pruebas unitarias:

cargo test

veremos que en realidad la prueba unitaria falla y eso es esperado porque estamos intentando construir un objeto Materia brindándole unos creditos_requeridos con el valor negativo -10.0 (también vemos que la prueba unitaria que habíamos hecho para probar si es posible inscribirse sigue pasando de forma exitosa)

Si leemos la descripción del motivo que la prueba unitaria no pasara es precisamente porque se levantó un error con panic!, entonces para probar que el error se levanta, sin hacer que la prueba unitaria falle, vamos a utilizar el macro #[should_panic] y también quitaremos el assert_eq!(1,1) ya que no será necesario, la prueba unitaria se verá de esta forma:

        
        
            
#[test]
#[should_panic]
fn levanta_un_error_si_los_creditos_proporcionados_no_estan_en_el_rango_esperado() {
   let _materia = Materia::new(
       String::from("matematicas"),
       -10.0,
       0.5
    );
}

        
        
    

El código completo en src/lib.rs quedaría así:

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        if creditos_requeridos > 0.0 && creditos_requeridos <= 10.0 {
            Self{
                nombre,
                creditos_requeridos,
                factor_creditos
            }
        } else {
            panic!("Creditos requeridos deben ser mayor a 0.0 y menor o igual que 10.0")
        }

    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

    #[test]
    #[should_panic]
    fn levanta_un_error_si_los_creditos_proporcionados_no_estan_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            -10.0,
            0.5
        );
    }
}

        
        
    

Si ahora ejecutamos las pruebas unitarias:

cargo test

Ahora sí veremos que la prueba unitaria pasa porque el error se está ejecutando y es precisamente lo que el test espera:

Pero si queremos agregar otra validación como por ejemplo que también el factor_creditos se encuentren entre 0.1 y 1.0 tendríamos dos tipos de error diferentes, entonces el macro #[should_panic] a secas, no podría distinguir cuál de las validaciones a sido la que provoca el error, pero para nuestra fortuna, tiene un parámetro que nos permite pasar un substring de lo que queremos evaluar, vamos a modificar el constructor para poner la validación y también vamos a agregar una prueba unitaria para cada validación, el código en lib/src.rs quedaría así:

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        if creditos_requeridos < 0.0 || creditos_requeridos > 10.0 {
            panic!("Creditos requeridos deben ser mayor a 0.0 y menor o igual que 10.0")
        } else if  factor_creditos < 0.1 || factor_creditos > 1.0 {
            panic!("Factor de creditos debe ser mayor o igual a 0.1 y menor o igual que 1.0")
        } else {
            Self{
                nombre,
                creditos_requeridos,
                factor_creditos
            }
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

    #[test]
    #[should_panic(expected = "Creditos requeridos deben ser mayor")]
    fn levanta_un_error_si_los_creditos_proporcionados_no_estan_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            -10.0,
            0.5
        );
    }

    #[test]
    #[should_panic(expected = "Factor de creditos debe ser mayor o igual a")]
    fn levanta_un_error_si_factor_de_credito_no_esta_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            9.0,
            1.2
        );
    }

}

        
        
    

Si vemos ahora hay más validaciones o dos tipos de errores en el constructor de Materia:

        
        
            // Constructor Materia

pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        if creditos_requeridos < 0.0 || creditos_requeridos > 10.0 {
            panic!("Creditos requeridos deben ser mayor a 0.0 y menor o igual que 10.0")
        } else if  factor_creditos < 0.1 || factor_creditos > 1.0 {
            panic!("Factor de creditos debe ser mayor o igual a 0.1 y menor o igual que 1.0")
        } else {
            Self{
                nombre,
                creditos_requeridos,
                factor_creditos
            }
        }
}

        
        
    

Si utilizamos el #[should_panic] a secas, no habría forma de determinar cual de las validaciones es la que se ejecuta, pero el macro tiene un parámetro "expected" al cual se le puede pasar un substring de la validación que queremos probar, en este caso han quedado así:

        
        
            // Pruebas unitarias con should_panic que diferencia el tipo de error

    #[test]
    #[should_panic(expected = "Creditos requeridos deben ser mayor")]
    fn levanta_un_error_si_los_creditos_proporcionados_no_estan_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            -10.0,
            0.5
        );
    }

    #[test]
    #[should_panic(expected = "Factor de creditos debe ser mayor o igual a")]
    fn levanta_un_error_si_factor_de_credito_no_esta_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            9.0,
            1.2
        );
    }

        
        
    

Hasta acá hemos visto cómo construir pruebas unitarias!!!

Opciones Extras Para Cargo Test

cargo test tiene varias opciones que nos pueden ser de utilidad, quisiera compartirte algunas de estas opciones que me parecen más relevantes

Hilos de Ejecución de Pruebas Unitarias

Algo que no te había comentado es que cargo ejecuta las pruebas unitarias en paralelo, es decir que abre un hilo o thread por prueba unitaria, si te gustaría no utilizar los hilos, sino, que ejecutar las pruebas de forma secuencial lo puedes hacer con el comando (observa que hay un -- antes del --test-threads):

cargo test -- --test-threads=1

Mostrar mensajes al ejecutar las pruebas unitarias.

Si dentro de tus pruebas unitarias colocas mensajes con println! o con alguna macro de debug, al ejecutar las pruebas con cargo test, observarás que los mensajes no se visualizarán a menos que la prueba unitaria falle, si quieres mostrar los mensajes aún cuando la prueba unitaria tiene éxito, entonces puedes pasarle al comando el parámetro --show-output

cargo test -- --show-output

Ignorar casos de prueba.

En ocasiones quieres ignorar algún caso de prueba que ha quedado desactualizado o simplemente para probar otros casos de prueba de forma más rápida, podemos ignorarlos incorporando el macro #[ignore] por ejemplo:

        
        
            
    #[test]
    #[ignore]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

        
        
    

Filtrar Casos de Pruebas.

Si quieres ejecutar solamente algunos casos de pruebas puedes filtrarlos utilizando substrings en base a los nombres de las pruebas unitarias, por ejemplo vamos a incorporar un par de casos de prueba extra en src/lib.rs que tendrán el substring puede_inscribirse_si_los_creditos_, en este caso vamos a tener tres casos de prueba que tiene ese substring en el nombre.

        
        
            // src/lib.rs

#[allow(dead_code)]
pub struct Materia {
    nombre: String,
    creditos_requeridos: f32,
    factor_creditos: f32 // Servira para multiplicarlo por una nota y brindar los creditos
}

impl Materia {

    pub fn new(nombre: String, creditos_requeridos: f32, factor_creditos: f32) -> Self {
        if creditos_requeridos < 0.0 || creditos_requeridos > 10.0 {
            panic!("Creditos requeridos deben ser mayor a 0.0 y menor o igual que 10.0")
        } else if  factor_creditos < 0.1 || factor_creditos > 1.0 {
            panic!("Factor de creditos debe ser mayor o igual a 0.1 y menor o igual que 1.0")
        } else {
            Self{
                nombre,
                creditos_requeridos,
                factor_creditos
            }
        }
    }

    pub fn puede_inscribirse(&self, creditos_estudiante: f32) -> bool{
        creditos_estudiante >= self.creditos_requeridos
    }
}

pub struct Calificacion {
    materia: Materia,
    nota: f32
}

impl Calificacion {
    pub fn creditos_obtenidos(&self) -> f32 {
        self.materia.factor_creditos * self.nota
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn puede_inscribirse_si_los_creditos_son_mayores_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 11.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

    #[test]
    fn puede_inscribirse_si_los_creditos_son_iguales_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 10.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, true);
    }

    #[test]
    fn no_puede_inscribirse_si_los_creditos_son_iguales_a_los_requeridos() {
        let materia = Materia::new(
            String::from("matematicas"),
            10.0,
            0.5
        );

        let creditos_a_evaluar = 1.0;
        let resultado_obtenido = materia.puede_inscribirse(creditos_a_evaluar);

        assert_eq!(resultado_obtenido, false);
    }

    #[test]
    #[should_panic(expected = "Creditos requeridos deben ser mayor")]
    fn levanta_un_error_si_los_creditos_proporcionados_no_estan_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            -10.0,
            0.5
        );
    }

    #[test]
    #[should_panic(expected = "Factor de creditos debe ser mayor o igual")]
    fn levanta_un_error_si_factor_de_credito_no_esta_en_el_rango_esperado() {
        let _materia = Materia::new(
            String::from("matematicas"),
            9.0,
            -0.5
        );
    }

}

        
        
    

Ahora podemos filtrar esos casos de prueba con este comando:

cargo test puede_inscribirse_si_los_creditos

Veremos que únicamente ejecuta los tres casos de prueba con el substring:

Recuerda que puedes ver el código completo de los ejemplos en nuestro repositorio en github dando click acá. En las siguientes publicaciones continuaremos explorando como hacer pruebas de integración con Rust.

Espero esta publicación te haya sido de utilidad, recuerda compartirla en tus redes sociales y con tus amigos 😁

Deja en la caja de comentarios los temas que te gustaría aprendamos sobre Rust y no te olvides de seguirme en twitter.

println!("Hasta la próxima!");


 Utilizamos cookies propias y de terceros para mejorar tu experiencia, mostrar publicidad y análisis de navegación, puedes encontrar el detalle en nuestra Política de Cookies