Fork me on GitHub

TDD Cómo y porqué: Una guía para los no iniciados

Por alfredochv

Introducción

Esta es mi primera contribución para Artesanos de Software.

Soy un desarrollador de software “de la vieja escuela”, por decirlo de alguna manera. Hace años, pensaba que tenía bastante dominado este asunto de la programación, hasta que me topé con Extreme Programming y todo el ecosistema de Métodos Ágiles que se ha desarrollado desde entonces. Para mi, todo cambió desde entonces y me dí cuenta de lo poco que sé en realidad. He estado practicando la disciplina del Desarrollo Dirigido por Pruebas o TDD ahora si y ahora no por los últimos ocho años. Quienes me conozcan, sabrán que no ha sido ni por mucho un proceso fácil ni mucho menos rápido. Sin embargo, si solo pudiera lograr que todos los desarrolladores de mi equipo adoptaran una sola práctica de XP, esa sería TDD.

Las excusas abundan, es simplemente demasiado fácil ceder y dejar de hacerlo. Desde los clásicos “en mi empresa no me lo permiten” y “mi jefe dice que me pagan por escribir funcionalidad, no pruebas” hasta los no tan infrecuentes “las pruebas son trabajo de QA, no mio” o “esta porquería es una mi3rd@, ¡es imposible de probar!”. Lo sé porque yo mismo he estado en esas situaciones, he dado las mismas excusas y ni una sola vez puedo decir que el resultado haya sido positivo para mi o para el proyecto.

El propósito de este blog es allanar un poco el camino para aquellos que estén considerando aprender TDD y posiblemente utilizarlo en su trabajo o en proyectos personales. Para mi no ha sido fácil y aún estoy aprendiendo. Si alguien puede sacar algo en claro de mis propias experiencias pasadas, creo que eso será más que suficiente para compensar el tiempo invertido en el mismo.

Este blog debe considerarse un trabajo en progreso. La idea del mismo comenzó hace probablemente tres o cuatro meses. Simplemente hay demasiados temas y ángulos para cubrir de una sola vez. El material disponible en la red, las anécdotas personales, las técnicas, los “pitfalls” (alguien, español por favor?), los “tips”, etc. son suficientes como para llenar uno o varios libros así que no puedo esperar cubrir todas las bases en un solo post. Debido a ello, en este post me concentraré exclusivamente en el tema de las pruebas unitarias. Solo el tiempo dirá si soy capaz de llevar este proyecto a buen término :-)

¿Qué es TDD?

TDD es una criatura extraña. Es simple de definir, pero su definición parece ir en contra del sentido común. Es sencilla de explicar, pero difícil de llevar a cabo. Y una vez que superas la resistencia intelectual inicial (the “WTF factor”) y lo entiendes, es difícil de dominar.

Definición

Es una disciplina que promueve el desarrollo de software con altos niveles de calidad, simplicidad de diseño y productividad del programador, mediante la utilización de una amplia gama de tipos de pruebas automáticas a lo largo de todo el ciclo de vida del software. El principio fundamental es que las pruebas se escriben antes que el software de producción y estas constituyen la especificación objetiva del mismo.

La primera parte de la definición suena todo miel sobre hojuelas. ¿Quién no quiere software confiable, bien diseñado y producido rápidamente?. Sin embargo, todo esto no viene gratuitamente; la palabra clave aquí es disciplina.

Disciplina:

Doctrina, instrucción de una persona, especialmente en lo moral. Observancia de las leyes y ordenamientos de la profesión o instituto.

Esto nos lleva a la conclusión de que si TDD es en efecto una disciplina, entonces no es algo que aplicamos “según nos vayamos sintiendo”, más bien, es algo que debe formar parte integral de nuestra profesión o arte (según la perspectiva de cada quien).

La segunda parte de la definición viene con el primer “WTF” para muchos: Las pruebas se deben escribir antes que el software mismo. La primera impresión de muchos (incluyendo a yours truely) es “¿eh?, ¿y cómo demonios escribo una prueba para software que todavía no existe?”.

Cuando aprendemos a programar, los más afortunados comienzan con algún lenguaje interpretado como Basic, Logo o Scheme (para los más veteranos) o Ruby y Python. Normalmente comenzamos con cosas simples como por ejemplo, sumar 2 y 3:

>>> 2 + 3
5

Intuitivamente pensamos “debe dar cinco”, incluso antes de oprimir la tecla ENTER; y normalmente funciona o si no, entonces hay algo definitivamente mal con el lenguaje o con nuestro entendimiento del mismo. Posteriormente pasamos a cosas más complejas y/o sofisticadas, como por ejemplo:

>>> a = 2
>>> a + 3
5
>>> b = 3
>>> a + b
5
>>> def sum(a, b):
...   return a + b
...
>>> sum(2, 3)
5

Etcétera. Todo el tiempo verificamos que el resultado es el que esperamos, aunque “sabemos” que así debería ser. Cada vez que vemos el resultado que esperamos aparecer en la pantalla, aumenta nuestra autoconfianza, lo que nos motiva a seguir aprendiendo, a seguir programando. Este podría tal vez ser el ejemplo más básico de TDD.

Sin embargo, una vez que tomamos mayor confianza en nuestro dominio del lenguaje o la programación misma, comenzamos a escribir cantidades cada vez mayores de código entre una comprobación y la siguiente del resultado. Como “sabemos” -en realidad, creemos- que nuestro código “esta bien”, comenzamos a “optimizar el tiempo” escribiendo más y más código de una vez. Al poco tiempo, nos olvidamos de estas primeras experiencias, incluso tachándolas como “cosas de novatos”.

Aprendiendo TDD

Fast forward al presente y nos encontramos a nosotros mismos tratando de aprender TDD. Nos conseguimos una copia de JUnit, NUnit, o el framework de moda para nuestro lenguaje de elección y comenzamos a seguir el tutorial que seguramente encontraremos en el sitio de este último. Los más afortunados probablemente tendrán integrada la funcionalidad directamente en su IDE.

A partir de aquí, estamos en la parte sencilla de nuestra curva de aprendizaje. En los próximos días comenzaremos a producir grandes cantidades de pruebas y no tardaremos en sentirnos cómodos con el proceso. Esto es lo más lejos que la mayoría llegamos en la curva y es aquí justamente donde comienzan los problemas.

Conforme comenzamos a intentar escribir pruebas para proyectos más complejos o incluso en el trabajo nos topamos con varios obstáculos en el camino:

Finalmente nos damos por vencido y abandonamos completamente nuestras mejores intenciones y pensamos “Simplemente no vale la pena”.

Estamos en la parte más pronunciada de nuestra curva de aprendizaje. Tal vez estamos produciendo muchas pruebas, y estamos obteniendo verdadero valor de las mismas. Sin embargo el esfuerzo para escribir/mantener estas mismas parece desproporcionado. Sin embargo, como cualquier otra habilidad que valga la pena adquirir, si en lugar de rendirnos seguimos adelante, eventualmente aprenderemos a cruzar a la parte de nuestra gráfica donde la pendiente de la curva se invierte y comenzamos a escribir pruebas más efectivas con un menor esfuerzo y a cosechar los beneficios de nuestra perseverancia.

Aprender a escribir bien y de mantener las pruebas toma tiempo y práctica. El propósito de este blog es, en parte para ayudar a acelerar un poco este proceso, de forma que no se tenga que escribir muchas pruebas basura, imposible de mantener antes de comenzar a ver la luz al final del túnel.

Las reglas de TDD

Robert C. Martin (también conocido como “Tío Bob”), es una de las autoridades en TDD. En varias ocasiones ha descrito el proceso en base a tres simples reglas:

  1. No está permitido escribir ningún código de producción sin tener una prueba que falle.
  2. No está permitido escribir más código de prueba que el necesario para fallar (y no compilar es fallar).
  3. No está permitido escribir más código de producción que el necesario para pasar su prueba unitaria.

Esto significa que antes de poder escribir cualquier código, debemos pensar en una prueba apropiada para él. Pero por la regla número dos, ¡tampoco podemos escribir mucho de dicha prueba! En realidad, debemos detenernos en el momento en que la prueba falla al compilar o falla un assert y comenzar a escribir código de producción. Pero por la regla número tres, tan pronto como la prueba pasa (o compila, según el caso), debemos dejar de escribir código y continuar escribiendo la prueba unitaria o pasar a la siguiente prueba.

Creo que esto se verá mejor con un pequeño ejemplo:

Obviamente he resumido el proceso un poco debido a limitaciones de espacio, pero creo que el proceso es claro.

Podemos ver que en realidad nunca escribimos mucho código de una sola vez. ¡Y de eso se trata precisamente! Es mucho muy similar al proceso descrito al principio de este post, cuando probábamos nuestro código interactivamente en el intérprete. Una iteración completa por todo el ciclo toma solo unos segundos o máximo un par de minutos. La retroalimentación se mantiene alta y esto nos motiva a seguir adelante con confianza y determinación. ¿Porqué? Simple: en todo momento, si seguimos este proceso durante todo el día, sabemos que nuestro sistema está funcionando. Incluso si comentemos un error hace solo un momento el sistema funcionaba correctamente. Si introducimos un bug, único que hace falta es oprimir Ctrl-Z unas cuantas veces y podremos regresar a nuestra barra verde. Y creo firmemente que eso es algo valioso.

Uno de los problemas fundamentales a los que me he enfrentado a través de los años al desarrollar software, es el no entender bien lo que estoy haciendo en un momento dado. Hay veces que simplemente estoy tratando de entender una nueva API o sistema y debo escribir código para implementar nueva funcionalidad. Así que copio y pego código que encuentro en algún libro o sitio de internet y trato de hacerlo funcionar. Un cambio aquí, otro allá hasta que aparentemente funciona. El problema es que no entiendo lo que acabo de hacer. Si el código falla en QA o incluso un par de semanas después de haberlo escrito, realmente no tengo mucha idea de porqué. De hecho, no tengo idea de porqué funcionó cuando lo puse ahí en primer lugar.

Al seguir de forma disciplinada estas tres simples reglas, nunca paso demasiado tiempo sin saber si lo que hago funciona o no. Y como nunca escribo demasiado código, puedo entender plenamente cómo y porqué funciona.

Escribiendo pruebas unitarias efectivas

Roy Osherove en The Art of Unit Testing dice que las buenas pruebas tienen tres propiedades comunes: son legibles, confiables y fáciles de mantener. Una cuarta propiedad que yo agregaría es “rapidez”, por razones que discutiremos más adelante.

Legibilidad

Una prueba legible es aquella que revela su propósito o razón de ser de forma clara. Básicamente, qué es lo que la prueba ha de demostrar. Una parte importante de la legibilidad de una prueba consiste simplemente en darle un nombre apropiado. Si está probando una pila, por ejemplo, entonces no llamemos nuestras pruebas testStack_01, testStack_02, etc. No solo son nombres bobos (por decir lo menos) sino que lo único que revelan es que en alguna parte debe haber algún objeto o función llamado “Stack” involucrado. En cambio, elegir nombres que reflejen el comportamiento útil observable que el código debiera exhibir. Por ejemplo, testElementosGuardadosSonRegresadosEnOrdenInverso es un nombre que describe un comportamiento observable de las pilas: los elementos colocados al principio son los últimos en ser devueltos.

Es conveniente considerar que los nombres de las pruebas forman parte de la documentación del comportamiento de la Unidad de Código Bajo Prueba. Cuando llega el momento de implementar una nueva clase, a menudo encuentro útil comenzar con una lista inicial de las pruebas que quiero escribir (no siempre lo hago, pero a veces resulta indispensable). Puedo usar esta lista como un primer borrador de la especificación de la clase en cuestión, por ejemplo:

Esta lista más adelante puede convertirse en la base de los nombres de nuestras pruebas.

Cuando las pruebas llevan el nombre de una conducta observable, esta tiende naturalmente a reflejar unicamente este aspecto del código. Es aceptable tener más de un assert, siempre que estos se refieran a una sola cosa, generalmente a un solo objeto.

Encontrar el justo equilibrio entre tener el código de inicialización dentro de las pruebas, en una fábrica o en un método setup dedicado, es también un elemento importante de la legibilidad. Es importante reducir el volumen del código en las pruebas, pero también queremos que sea evidente lo que la prueba está haciendo. Es fácil caer en la trampa de ocultar muchos detalles en los métodos de inicialización o de fábrica, por lo que un lector tiene que buscar estos métodos para poder entender la prueba. El principio DRY, a veces se encuentra firmemente grabado en la consciencia de los buenos programadores. Sin embargo, es perfectamente aceptable tener un poco más de redundancia, mientras que el propósito se mantenga claro.

Esto último no quiere decir que podemos ignorar las reglas y escribir nuestras pruebas de forma descuidada. Nuestras pruebas son parte esencial de nuestro código. Son tan importantes como el código de producción (o de acuerdo con Robert C. Martin, son aún más importantes). Por lo tanto es necesario poner tanto esmero en su manufactura como el que pondríamos en la demo que haremos la próxima semana frente al cliente.

Confiabilidad

Una prueba confiable es la que falla o pasa de forma determinista. Las pruebas que dependen si la computadora está configurada correctamente, o cualquier otro tipo de variables externas, no son confiables, porque no es posible saber si una falla significa que el equipo no está configurado correctamente, o si el código contiene errores.

Estas pruebas que dependen de variables externas son en realidad pruebas de integración, y se deben poner en un proyecto por separado, junto con alguna documentación sobre la forma de ponerse en marcha. Esto es deseable, ya que este tipo de pruebas normalmente se ejecutan mucho más lentamente que las pruebas unitarias típicas, por lo que al estar separadas, no impedirán que ejecutemos nuestras pruebas unitarias tan frecuentemente como deseemos/necesitemos.

Una variable externa es cualquier cosa sobre la que no tenemos control directo: el sistema de archivos, bases de datos, el tiempo, el código de terceros, etc.

En cuanto al tiempo, basta con crear algunas instancias tipo fecha con un instante fijo, en lugar un indeterminado “tiempo actual” y olvidarnos del asunto. En una prueba unitaria, deberíamos utilizar exactamente los mismos datos de prueba cada vez, pero si la prueba dependiera de un valor como DateTime.Now, entonces efectivamente sería una prueba diferente cada vez que se ejecute.

En algunas ocasiones especiales, es imposible evitar el tener una prueba indeterminable sin importar cuanto nos esforcemos. Martin Fowler y otros recomiendan en primer lugar, aislar estas pruebas. Lo último que queremos es acostumbrarnos a ver fallar pruebas en nuestra suite. Una barra roja para nosotros siempre debe ser una señal de alarma. No importa que podamos reconocer la prueba por su nombre. El punto de usar pruebas automáticas es precisamente no tener que inspeccionar visualmente los resultados para darlos o no por buenos. Si esto sucede, ¡podemos pasar por alto un fallo real sin notarlo! Otro punto es el analizar si una aproximación probabilística es útil en estos casos. Si los resultados de la prueba se encuentran acotados dentro de un margen de tolerancia, es posible eliminar la incertidumbre hasta un grado aceptable para nuestros propósitos.

Mantenibilidad

Una prueba fácil de mantener es aquella que no “se rompe” fácilmente cuando se les da mantenimiento. Un bajo acoplamiento es probablemente el factor más importante para la facilidad de mantenimiento. El uso de Métodos de fábrica nos permite desacoplar nuestras pruebas de los constructores de clase, que tienden sufrir cambios en sus listas de parámetros más a menudo que otros métodos.

Dar nombres significativos a nuestras pruebas también es importante para el mantenimiento, así como la legibilidad. Cuando se puede deducir a partir del nombre lo que la prueba está tratando de comprobar, se puede ver si en realidad el código hace lo que se dice que está haciendo. Puede asegurarse que las pruebas mantienen su comportamiento, incluso cuando hay cambios en el API que utiliza.

Cuando las pruebas se pueden leer, entonces se vuelven más fáciles de mantener. Cuando las pruebas se pueden mantener, entonces es probable que en de hecho, se les dé mantenimiento. Cuando se sabe que las pruebas se mantienen, y se puede inferir lo que están comprobando, entonces es posible confiar en ellas como en la red de seguridad que se supone que son.

Rapidez

Una prueba unitaria efectiva debería ejecutarse en milisegundos, no en segundos. Si a una prueba tarda más que algunos cientos de milisegundos en ejecutar, es probable que debamos considerarla demasiado lenta. Como en todo, hay excepciones, pero esta es una buena regla a seguir. A continuación explico porqué.

Una suite de pruebas puede llegar a contener decenas o incluso cientos de pruebas, organizadas en clases, cada una enfocándose a un aspecto particular del código. Si una prueba se ejecuta en un segundo (1000 milisegundos) y tenemos cien de ellas, tendremos que esperar más de dos minutos para conocer el resultado de las mismas. Correr la suite se convierte en si mismo en una interrupción que altera nuestro ritmo de trabajo: La mente se distrae y para cuando finalmente tenemos los resultados, debemos de “vaciar la pila” nuevamente y reingresar el contexto que teníamos unos segundos antes de ejecutar la suite.

Si hacemos esto una y otra vez en el transcurso del día (a la mayoría de los desarrolladores se les complica mantener su atención por más de algunos segundos antes de perder el interés), entonces comenzaremos a evitar el correr nuestras pruebas, lo cual deberíamos hacer cada vez que cambiamos “algo” en el código. Y si esto es así, entonces perdemos la confianza en nuestros cambios y en nosotros mismos. Regresamos al ritmo “tradicional” y finalmente puede llegar a parecernos “más fácil” abrir el depurador de nuestro IDE que dar un par de pasos hacia atrás hasta el punto en que todo aun funcionaba bien.

Todo ello sin considerar el tiempo perdido. No solo el tiempo de ejecución de las pruebas mismas, sino el tiempo que nos toma volver a entrar en el contexto mental que teníamos justo antes.

Una sesión de TDD en realidad debería transcurrir como los ejemplos interactivos al principio de este post. Escribimos algo de código y deberíamos obtener retroalimentación casi inmediatamente. Cada vez que corremos la suite “en verde”, aumenta nuestra confianza, en nuestro código, en nuestra suite y en nosotros mismos, lo cual nos mantiene altamente motivados para seguir adelante.

Volver al ciclo tradicional de modificar/compilar/debuguear destruye esa motivación. Si probar un cambio de una sola linea nos lleva 5 minutos de en el depurador, nuestra motivación se va a los suelos y se convierte en una excusa para alargar los tiempos de desarrollo casi infinitamente.

Un componente fundamental en la construcción de una suite de pruebas es la habilidad de construirla a partir de subconjuntos más pequeños y enfocados. Es importante ser capaz de probar el sistema completo oprimiendo solo un botón (o con un solo comando en la shell del sistema), pero igualmente importante es poder ejecutar únicamente las pruebas para la clase o el sub-sistema que estamos probando en este momento. La mayoría de los frameworks de la familia XUnit tienen esta capacidad. Se pueden crear suites pequeñas y estas a su vez, incluirlas en suites mayores.

Cookbook

Aprender

Prácticas para el día a día

Espero como siempre que haya sido de utilidad.


comments powered by Disqus