La importancia del Unit Testing en el desarrollo de software

 

coding

coding

A menudo nos encontramos con bugs (fallos) en los sistemas en producción en los que siempre surge la aseveración: “Se podía haber evitado con un test unitario”. Esto no solamente conlleva un problema de calidad de código, al cliente también le cuesta mucho más dinero pagar por arreglar bugs detectados en producción que en desarrollo.

Todo lo que se solucione en el ciclo de desarrollo no cuesta otra cosa más que horas y trabajo de desarrollo con tests. A mucha gente le resultará familiar el hecho de que una transacción no se haya podido completar porque un log estaba mal codificado y se lanzaba un habitual null pointer exception (excepción por puntero nulo) o error interno que nos rompe la ejecución.

Esto es muy fácilmente evitable con un test unitario que cubra esa línea de código; no importa que el log no se escriba realmente en un sistema de ficheros, pero sí que se ejecute la línea en cuestión; al menos asegurarse que todas las líneas de las partes críticas son ejecutadas por algún test unitario o varios.

Es común no entender bien la diferencia entre un test unitario y un test de integración. A menudo, se confunden e identifican test de integración con unitarios cuando la filosofía es muy diferente:

En un test de integración se busca probar una funcionalidad completa, end to end (extremo a extremo). Simulas un caso de ejecución en la vida real, a menudo con una base de datos (BBDD) levantada en memoria o incluso un servidor web.

Estamos ejecutando el mismo código que se ejecutaría en live con el sistema en funcionamiento; un ciclo completo de nuestro código simplemente utilizando una BBDD fake o un servidor web ligero.

En un test unitario buscamos probar un método concreto; no nos interesan las third parties ni dependencias externas que se llamen internamente.

Queremos que el método reciba unos inputs (entradas) y produzca los outputs (salidas) que nosotros esperamos. Nos interesa también que todo el código quede cubierto, es decir, que siempre se pase por todas las líneas escritas (incluidas excepciones). Se suelen usar mocks (modelos simulados) para sistemas complicados externos y aserciones.

Estos tests nos conducen al concepto de cobertura de código que ya se ha mencionado.

Qué nos aporta

Si pensamos en las ventajas de tener un código con unit testing, surge inmediatamente el concepto de cobertura de código como ya se ha apuntado.

Un código con cobertura del 100% significa que existe un test unitario que provoca la ejecución de esa línea de código; No obstante ,conseguir una cobertura del 100% es complicado y muchas veces lleva más tiempo de lo que pudiéramos obtener como beneficio.

Las partes más sensibles del código deberían estar cubiertas al máximo, mientras que POJOs (objetos simples), código generado por plugins, etc., no es tan necesario que lo estén. Tener una línea cubierta no sólo sirve para aumentar un número y cumplir con hitos o que el Jenkins (herramienta de integración continua) tenga un color verde; significa que si alguna vez la ejecución del programa pasa por esa línea, no nos encontraremos el típico problema de ejecución en live que provoque un error no controlado.

Si hacemos un refactor y muchos tests prueban diferentes patrones de comportamiento de ese código, estaremos seguros de que el refactor ha sido exitoso.

Con una amplia cobertura de código y con test unitarios, la seguridad a la hora de hacer cambios o refactors es mucho mayor: tenemos la seguridad de que nuestros tests probarán los cambios. Más aún, cuando el número de líneas suben, existirán más tests y casos que siempre se olvidan quedarán cubiertos sin más que ejecutando los tests antes de un deploy (despliegue) local.

Aunque no es el objetivo fundamental, una persona nueva en el proyecto puede entender mejor la lógica de la aplicación viendo los unit tests que hay escritos. Incluso pueden servir para hacer uso de una aplicación de manera eficiente; es una documentación adicional a la que existe a nivel de método y de clase.

Si se piensa en desarrollo orientado a unit testing, las implementaciones estarán más desacopladas y existirá menos dependencia entre funciones; siempre se busca tener métodos que acepten unos parámetros y devuelvan otros, teniendo en la cabeza esta idea, los métodos oscuros que nos cambian las condiciones de las pruebas estarán más controlados y tenderán a desaparecer.

Un método que captura la hora del sistema internamente para hacer operaciones con ella en lugar de aceptarla como parámetro es susceptible a muchos fallos; no podemos controlar el comportamiento ante cambios de hora o antes ejecuciones muy largas del programa (imaginemos que hay un salto de día en una ejecución de dos horas); es bastante probable que como parámetro de entrada nos hubiéramos planteado tests que probaran estas cosas.