Artículos

miércoles, 25 de enero de 2017

La materia pendiente de muchos proyectos: buenos Unit Test y Code Coverage

Cuando no vemos la relación entre bugs y tests unitarios




Introducción


Este artículo trataré de exponer mis pensamientos respecto del tratamiento de bugs y la utilización de tests para tratar de mitigarlos

Escenario


Para trasmitir mejor la idea voy a armar el siguiente escenario

Complejidad del sistema

Supongamos que somos el arquitecto de un proyecto donde estamos construyendo una aplicación Legacy que utilizarán al menos 60 usuarios en forma diaria distribuidos en diferentes partes del mundo (con diferentes usos horarios). Además esta aplicación deberá tomar información de diferentes bases de datos, consumir servicios web externos y exponer sus propios servicios para otros sistemas. Por último, en la capa de negocio hay que desarrollar un motor de reglas que analizará los datos de servicios externos y despachará mensajes en una cola de trabajo (Ej. MSMQ).

Estado del proyecto

Supongamos que nos toca ingresar en un momento donde el proyecto aún está en estado de desarrollo (Beta) a 1 mes de ponerse en producción (Release 1.0)
Mientras estamos en pleno desarrollo se supone que el equipo de testing estuvo probando la aplicación y nos fue devolviendo aquellos errores detectados por el uso manual del sistema, los cuales se fueron corrigiendo al finalizar cada sprint.


Tamaño del equipo


Por último, nuestro equipo está compuesto de 14 desarrolladores, 2 analistas funcionales 3 testers, 1 líder de proyecto y 1 arquitecto (nosotros, porque el anterior renunció el mes pasado).


Puesta en marcha



Hasta que nos acomodamos para entender el negocio, la arquitectura y el estado del proyecto se pone en producción el Release 1.0....y a la semana comienzan a llover bugs. Pero no bugs relacionados con la interfaz gráfica (scripts rotos, urls erróneas, fallas en la maquetación, etc.) sino fallas en el procesamiento de la misión del sistema: produce información errónea.
El cliente se pone nervioso y no entiende cómo puede suceder tanta cantidad de bugs con la primera versión del producto. Nosotros que ya tenemos experiencia en desarrollo tratamos de explicarle que normalmente los primeros releases tienen más bugs que los siguientes pero que pronto se reducirán.

Pero al mirar bien el proyecto de Test nos damos cuentas que más del 50% de los tests se marcaron como obsoletos...porque cuando se crearon los requerimientos estaban muy inmaduros y cambiaban constantemente. Con lo cual el costo de actualizarlos era altísimo.


Análisis


Ante esta situación queremos ver la foto completa del problema y ejecutamos el análisis de Code Coverage de Visual Studio. Inmediatamente vemos que en la capa de negocio y particularmente donde está el motor de reglas el porcentaje de cobertura es muy bajo.
Por si fuera poco, los test unitarios que están activos no siguen una convención de nombres. Y al inspeccionar más a fondo vemos que muchos de ellos tienen múltiples Asserts y se están utilizando Mocks (en lugar de Stubs) para verificar métodos ejecutados que no son relevantes.

Tomando el toro por las astas


Ante una situación como la mencionada tenemos que tomar decisiones muy importantes, algunas de ellas opuestas o mutuamente excluyentes:

  • Dejamos que el área de QA canalice todos los bugs y los reporte a medida que continuamos con el desarrollo del sistema para corregirlos en cada sprint.
  • Le explicamos al cliente el problema que hay en los tests automáticos debido a la baja calidad que hubo en los requerimientos en la fase de construcción del primer release y pedimos usar dentro del sprint ciertos story points para corregir los tests.
  • Descartamos los tests obsoletos y solo trabajamos con lo que reporte QA.
  • No le decimos nada al cliente para no develar la falla pero inflamos la estimación de otros casos para meter por debajo arreglos de los tests obsoletos.
Estas u otras acciones que se pudieran tomar tienen de fondo una cuestión fundamental:

Asumir que hay una relación directa entre los bugs que se presentan y la falta de unit tests en la capa de negocio para tratar de solucionarlos ... o .... pensar que los bugs se deben a la falta de eficacia del área de QA y por lo tanto dejar como están los tests obsoletos.

Personalmente tengo la convicción de que gran parte de los bugs vinculados a las reglas de negocio se pueden mitigar con la creación de una batería de tests unitarios que además se ejecuten como parte del proceso de integración continua y ante cualquier falla en los test se rompa el build directamente ( o incluso se rompa porque el porcentaje de cobertura de código en la capa de negocio sea menor a un límite establecido).

Lo que sucede es que hacer este tipo de test lleva bastante tiempo, comprensión de negocio y , por que no decirlo, son algo que a los desarrolladores no les gusta hacer en general.

La elaboración de tests unitarios que formen parte del proceso de integración continua tiene muchísimos beneficios. Tener la tranquilidad de que cualquier modificación o agregado de funcionalidad está siendo evaluado por un conjunto de pruebas unitarias nos garantiza que si rompemos alguna lógica ya definida será en plena etapa de desarrollo y no en el entorno de producción o QA.

TDD: una herramienta que nos pueden ayudar.


¿Cómo hacer para que los desarrolladores incorporen y valoren la generación de tests unitarios en las capas críticas de la aplicación?. ¿Cómo detectar aquellas partes que deben tener la mayor parte de Code Coverage?

Una buena forma de incorporar el hábito de realizar pruebas unitarias es siguiendo la metodología TDD (Test Driven Design). Muchos creen que esta metodología solo es aplicable en un contexto XP pero la realidad es que no es así.

Lo más importante de esta metodología radica en seguir estas etapas de madurez del código para cada historia que vamos a codificar:

Make it Fail: en esta etapa lo que estamos buscando es el código mínimo necesario para poder escribir una prueba y que ésta falle naturalmente porque el código está recién iniciado (es como crear solo el API donde cada método / propiedad tiene un "throw new NotImplementedException()". En este momento nos concentramos en el negocio, sus complejidad / bifurcaciones que deberán ser cubiertas con pruebas que fallen.

Make it Work: luego de construir un set de pruebas que tengan un nivel de cobertura suficiente entramos en esta etapa donde el objetivo es poner el código necesario para superar todas las pruebas que escribimos primero. El código es solo el necesario para superar los tests.



Make it Better: una vez que alcanzamos superar los test podemos concentrarnos en hacer del código una solución más elegante o más eficaz incluso. Aquí sí podemos pensar en aplicar patrones de diseño o cualquier refactor que prepare el escenario a casos similares (si es que realmente vemos van a existir). Lo bueno es que para el final del sprint tenemos asegurado el funcionamiento de la historia (o la parte de la historia que el código resuelve) como mínimo si es que no se llegó a superar esta etapa de Make it Better.

Por ejemplo, dentro de un sprint un desarrollador puede tomar un PBI (Product Backlog Item) y puede empezar a codificar por las pruebas unitarias (Make it Fail). Nadie puede prohibirle esto en tanto al fin del sprint el PBI esté terminado. De esta forma creará solo el código que es estrictamente necesario para cumplir con las pruebas (Make it Work) habiéndose concentrado en el negocio y evitando así violar los principios de desarrollo YAGNY, KISS y de alguna forma Single Responsibility. Al final del sprint este desarrollador podrá junto con sus colegas, líder técnico / arquitecto plantear un refactor para el siguiente sprint que permita aplicar un diseño más robusto para los siguientes casos similares (Make it Better)

En cuanto a qué parte de la aplicación debe tener mayor Code Coverage, en mi opinión (y no digo que está bien para todos los casos) depende del tipo de software a desarrollar.

En las aplicaciones Legacy (que es donde tengo mayor experiencia) es fundamental tener cubierto la mayor parte de casos para las reglas de negocio. Con esto me refiero al conjunto clases / métodos que ejecutan la parte lógica más crítica de una aplicación. Siguiendo el ejemplo del escenario planteado, el motor de reglas debe resolver un conjunto amplio de entradas combinadas con datos de algún origen para producir resultados en consecuencia de esta combinación. Todas estas variedades, combinaciones posibles de salidas que produce el motor deben tener la mayor cobertura de código posible.

Es cierto que a primera vista suena algo tedioso y llevará bastantes story points crear estos escenarios para las pruebas unitarias pero la inversión será directamente proporcional al nivel de calidad y solidez del producto (calidad en términos de reducción de bugs).
Por el contrario, cuanto menor sea el nivel de code coverage sobre estas reglas de negocio más carga de trabajo quedará del lado de QA con la casi inevitable perdida de tiempo en desarrollo por arreglar esos bugs. Pero lo peor de esta situación es que aquellas combinaciones raras que la gente de QA no llegue probar impactarán directamente como Bugs en producción (con toda la urgencia y presión que esto implica).

Legibilidad de los test unitarios


Es importante respetar algún tipo de convención en los nombres de los TestMethods para tener una clara legibilidad a simple vista.

Una estructura estándar es por ejemplo "MethodToTest_ExpectedBehavior_Scenario", la cual se pude ejemplicar como: "AnalyzeInput_MustReturnFalse_WhenDueDateIsEqualOrLessThanToday".

Como se puede apreciar, el nombre del método a testear es AnalyzeInput, lo que debe dar por resultado es False y la situación por la que debe fallar es porque la fecha de vencimiento es menor o igual a hoy.

Mantenibilidad


Un método a testear debería seguir las siguientes convenciones:

Arrange: en esta parte del método se preparan los mock / stubs necesarios, se arman los fake objects para tomar como input y todo aquello que haga falta para ejecutar el método en cuestión

Act: en esta parte se lleva adelante la ejecución misma del método a testear 

Assert: en esta parte se codifica el tipo de aserción que corresponda (IsTrue, IsNull, IsNotNull, etc.) en función de la ejecución de la parte Act.

Por supuesto que si al observar el conjunto de unit test a implementar requiere mayor preparación para el escenario de pruebas se pueden crear clases base para los unit test donde se resuelvan todos aquellos aspectos comunes a diversos test, además de clases auxiliares para estos test.

Confianza


Supongamos que meses más tarde de escribir un test unitario otro desarrollador tiene que modificar el código que generamos. Al terminar de ejecutar sus propios unit tests debería ejecutar todos los unit tests asociados a la compilación en el servidor de integración contínua para no romper el build y en ese momento quizás se de cuenta de que sus tests funcionan pero se rompe el que escribimos nosotros.
Esta situación es un excelente síntoma de que: o su código va en contra de otra condición de negocio que se debe respetar....o esa condición de negocio que se validaba ahora cambió y, por lo tanto, también hay que ajustar el unit test relacionado.

Y cómo llevar esta metodología a la práctica...?


El factor principal que no se mencionó aquí es el rol del arquitecto o líder técnico del equipo. Estas dos figuras deben ser promotores y guardianes de usar TDD en la elaboración de las historias dentro de un sprint. Esto implica que ellos deben estar al tanto de las historias que se intentan meter en una iteración y observar que allí se elaboren los criterios de aceptación necesarios, cada uno de los cuales tendrá probablemente una prueba unitaria asociada.

Es decir, ellos deben explicarle a cada desarollador que ingresa al equipo la forma de trabajo, ayudarlos con los primeros sprint a utilizar esta metodología ya que probablemente no estén acostumbrados a trabajar así.
Una buena ayuda es hacer code review a medida que los desarrolladores van avanzando para ver que se va entendiendo y cumpliendo la práctica.

La forma de medir la efectividad de esta forma de trabajo es observando los porcentajes de code coverage sobre las reglas de negocio críticas. Además si los bugs reportados por QA o por el cliente estan correctamente clasificados se podrá establecer la relación de cantidad funcionalidad entregada sobre la cantidad de bugs reportados.


Espero este artículo les haya sido de su interés y pueda haber ayudado un poco a clarificar algunas ideas respecto de cómo implementar TDD en el ciclo de desarrollo.

Hasta la próxima!