Contexto actual de las pruebas a nuestro código
Esta publicación pretende dar una visión de cómo se deben realizar buenas pruebas a nivel de código (unit test) en nuestra aplicación y está realizado bajo el lenguaje de programación Kotlin (con librería mockk) sin embargo lo que veamos puede ser fácilmente extrapolado a otro lenguaje de programación y librería para hacer pruebas. No pretende dar una visión de hacer TDD sino el cómo deben ser el resultado final de nuestras pruebas de código.
Demos un vistazo a las siguiente imagen que nos muestra el antipatrón de las pruebas:
Este gráfico nos muestra el cómo NO deben estar nuestras pruebas en código. Fácil de tender ¿no?. El deber ser es invertir nuestro cono (o pirámide) y que la base de todas nuestras pruebas sean unos buenos Unit Test. Sin embargo esto es bastante difícil de cumplir en la gran mayoría de empresas y por eso tienen departamentos enteros de QA encargados de validar si el desarrollo que hicimos está ‘ok’.
Estas pruebas manuales aparte de ser extremadamente costosas, lentas y depender de una persona para ejecutarlas es un modelo que no permite escalar fácilmente, quienes han trabajado en proyectos grandes habrán experimentado el cómo tardan las dichosas pruebas de nuestros compañeros QA y como sprint tras sprint se vuelve más y más difícil probar, el más mínimo cambio toma días en validarse.
La falacia del Coverage
El coverage se volvió la medida principal para “Garantizar” una buena base de pruebas a nuestro sistema (Dios que hemos hecho), incluso algunas empresas/proyectos un poco más maduros cuentan con prácticas como Continuous Integration en el que si un Pull request no cuenta con un buen porcentaje de coverage (lo común es ver 85%) no funciona.
No me malinterpreten, no estoy diciendo que el coverage sea malo y mucho menos que tener un CI lo sea, lo que trato de decir es que confiar únicamente en el coverage nos puede jugar una mala pasada, o si no pregúntese a sí mismos realmente ¿confío en las pruebas unitarias? Algunos podrán decir que Sí, en caso de que lo hagan pregunten ¿Pasamos a producción sin un proceso de QA? y verán como el miedo y ese aire frío de incertidumbre se apodera de su rostro. Esto sucede porque nuestras pruebas unitarias realmente no prueban nada, solo pasan por las líneas de código marcandolas como cubiertas haciendo el papel de un visitante que no hace verificaciones o aserciones de lógica de negocio valiosa.
¿Cómo identificar malas pruebas unitarias?
Esto se puede responder con bastante tecnicismos o incluso con librerías que pueden ayudarte (mutation test) a detectar esto, pero realmente por donde debemos empezar es ejecutando estas acciones:
- Borremos código y corramos nuevamente las pruebas.
- Invirtamos este condicional IF (o eliminarlo) y corramos nuevamente las pruebas.
- Quitemos la invocación de X componente externo y corramos nuevamente las pruebas.
Si ejecutando las pruebas con alguna de estas 3 acciones anteriores tus pruebas no se vieron afectadas, ¡Malas noticias! cuentas con malas pruebas unitarias.
Estas pruebas es el primer nivel para garantizar que nuestro código realmente esté realmente probado, veamos el segundo nivel:
Nuestras pruebas unitarias deben de cumplir un propósito, un para que existe, por decirlo de otra manera nuestras pruebas necesitan una razón de vida. Si no tienen una razón de ser elimínala cuantos antes esta es una mala prueba y te vas a llenar de falsos positivos.
Para lograr lo anterior nuestras pruebas deben estar orientada a garantizar un caso de negocio (uso) y velar porque se esté cumpliendo, puede sonar un poco complicado de cumplir cuando realmente no lo es, el secreto está en hacer un buen given-when-then. No hay que ser el developer mas estricto para lograr esto, solo es cuestión que la prueba tenga su propósito veamos esto en acción, ¡vamos al código!
Caso Hipotético:
Nuestro método de venta de libros debe verificar que la cantidad de libros no exceda 5, si lo excede debe impedir la venta y en caso de que sea 5 debe adicionar como regalo una membresía como lector premium a nuestro cliente.
Hagamos las pruebas de manera iterativa e incremental de cómo deben ser las pruebas.
- Esta “prueba” ejecuta el llamado del servicio
vean el resultado del coverage:
Tenemos 77% de coverage con una prueba que no hace absolutamente NADA
2. Agregamos a nuestra “prueba” 5 libros
Miremos el coverage:
Con este coverage ya pasará la mayoría de los CI los cuales tienen como mínimo 85% de coverage por cumplir.
¿Es más claro ahora lo peligroso que puede ser confiar en únicamente en el coverage?
3. Ahora apliquemos unos Mock sobre los llamados a ‘servicios’ externos que su funcionamiento interno no hace parte de las pruebas unitarias.
a. Separamos en un método independiente la carga de data que necesita nuestra prueba (Algo muy usado es enviarlo a una Clase encargada de generar data para los test)
b. Ingresamos un every para simular las respuestas de nuestros ‘servicios’ externos
c. Agregamos un verify para corroborar que se invocó el registro de una nueva membresía y en caso de que alguien modifique nuestro método de ‘sellBook’ y elimine el registro de membresía cuando se tenga 5 registros nuestra prueba unitaria fallará y MAGIA tenemos una prueba unitaria que realmente valida un comportamiento de negocio y en caso de que afecte el método nuestra prueba lo notificará
d. Como tal nuestro coverage se mantuvo con 86% con el cambio que realizamos, pero este 86% realmente nos da más confiabilidad que las anteriores iteraciones.
4. Ahora solo nos falta verificar el fallo cuando exceda la cantidad de libros
Con esta prueba garantizamos el comportamiento que debe suceder en nuestra aplicación si una venta viene con más de 5 libros y llegamos a un coverage del 100%
Tips: Para ayudar a nuestras pruebas unitarias validen comportamientos debemos apoyarnos principalmente en los verify, assertEquals.
Unas buenas pruebas unitarias te harán ahorrar horas de desarrollo
Para nadie que trabaje en esta industria es un secreto que el mayor tiempo que se dedica al proceso de ingeniería de software es el de mantenimiento, en el que una vez se finaliza al menos una primera versión del software salen y salen nuevos requisitos o ajustes por hacer a nuestro sistema tendiendo hacia el infinito y solo deteniéndose en el momento que nuestra aplicación es reemplazada por una mas nueva. Por lo tanto no sólo debemos preocuparnos por terminar nuestro desarrollo sino también que sea fácilmente mantenible y esto lo podemos lograr con unas buenas pruebas unitarias. Si han tenido la oportunidad de trabajar con código ya existente habrán sentido la angustia que se siente que nuestro nuevo desarrollo dañe algo que ya previamente funcionaba bien. Esta angustia puede ser fácilmente disminuida si al momento que el developer nuevo daña algo inmediatamente falla una prueba, con esto las horas y horas de debug en búsqueda de ese error que se nos presentó se verán eliminadas.
Tomen lo anterior como una invitación a dedicarnos un buen tiempo ha hacer buenas pruebas unitarias y ahorrarnos dolor de cabeza más adelante o a futuros developers de nuestra aplicación.
Por último les dejo estas dos frases que siempre comparto con los equipos que trabajo:
- Haz tu código pensando que la persona que lo mantendrá es un asesino psicópata y sabe donde vives.
- Si algo es difícil de probar es porque está mal desarrollado (Frase compartida por un gran colega Edwin Romero)