Test Driven Development

Publicado en

Durante los últimos ocho años he estado practicando esporádicamente la disciplina del Desarrollo Dirigido por Pruebas (TDD), lo cual no ha sido un proceso fácil ni rápido. El propósito de este artículo es allanar un poco el camino para aquellos que estén considerando aprender TDD y posiblemente utilizarlo en su trabajo o en proyectos personales.

TDD es un tema bastante amplio, por lo que en este artículo me concentraré exclusivamente en el tema de las pruebas unitarias.

¿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.

TDD 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", sino que es algo que debe formar parte integral de nuestra profesión o arte.

Las pruebas primero

La segunda parte de la definición de TDD viene con el primer "¡¿qué demonios?!" para muchos: Las pruebas se deben escribir antes que el software mismo.

Para entender que esto no es algo del otro mundo, recordemos cuando aprendimos a programar. Probablemente aprendimos con un lenguaje interpretado y 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; Si no, entonces hay algo definitivamente mal con el lenguaje o nuestro entendimiento del mismo. Posteriormente pasamos a cosas más complejas y/o sofisticadas, como por ejemplo:

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

Cada que vemos aparecer en la pantalla el resultado que esperamos, aumenta nuestra autoconfianza. Esto nos motiva a seguir aprendiendo, a seguir programando. Este podría tal vez ser el ejemplo más básico de TDD.

Una vez que tomamos mayor confianza, comenzamos a escribir cantidades cada vez mayores de código entre una comprobación y la siguiente del resultado. Como creemos que nuestro código "está bien", comenzamos a "optimizar el tiempo" escribiendo más y más código de un jalón. Al poco tiempo, nos olvidamos de estas primeras experiencias, incluso tachándolas como "cosas de novatos".

Llegamos 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 un tutorial que encontramos por ahí. A partir de aquí, estamos en la parte sencilla de nuestra curva de aprendizaje. Comenzamos a producir grandes cantidades de pruebas y no tardamos en sentirnos cómodos con el proceso.

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

  • Las pruebas se tornan difíciles de escribir, por lo que sentimos una desaceleración importante.
  • Las pruebas tardan en ejecutarse, por lo que nos volvemos renuentes a ejecutarlas frecuentemente.
  • Los cambios aparentemente sin importancia en el código provocan que un montón de pruebas fallen. Mantenerlas en forma y funcionando se vuelve complejo y consume tiempo.

Es en este punto donde la mayoría de las personas y equipos se da por vencido con TDD. Estamos en la parte más pronunciada de nuestra curva de aprendizaje. Estamos produciendo pruebas y obteniendo valor de las mismas, pero el esfuerzo para escribir/mantener estas mismas parece desproporcionado.

Al igual que con cualquier otra habilidad que vale 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. En el resto de este artículo comparto algunos tips para ayudar a acelerar un poco este proceso.

Las reglas de TDD

Robert C. Martin, una de las máximas autoridades en TDD, describe 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.

Esto se entenderá mejor con un pequeño ejemplo. Vamos a crear un código en Python que cuando le indiquemos un número entero, nos regrese un arreglo con sus factores primos.

Escribimos suficiente de nuestra primera prueba para que falle

from unittest import main, TestCase

 

class TestPrimeFactors(TestCase):

    def testPrimesOf0(self):

        self.assertEquals([], factorsOf(0))

 

if __name__ == '__main__':

    main()

Al correr este código se ejecuta la prueba “testPrimesOf0” y obtenemos un error "NameError: global name 'factorsOf' is not defined". Esta es nuestra señal para detenernos y agregar a nuestro código la definición de factorsOf:

def factorsOf(n):
    return []

Ahora ya pasa nuestra prueba. Así que podemos continuar escribiendo código de prueba. Reemplazamos nuestro caso de prueba para incluir el caso del 1.

def testPrimesOf0to1(self):
    self.assertEquals([], factorsOf(0))
    self.assertEquals([], factorsOf(1))

Funciona bien. Ahora agreguemos una prueba para el 2, el cual es un número primo y por lo tanto debe regresar un arreglo con su mismo valor.

def testPrimesOf2(self):
    self.assertEquals([2], factorsOf(2))

Como era de esperarse, la prueba falla, así que es hora de escribir código. Cambiamos la implementación de nuestra función factorsOf por:

def factorsOf(n):
    if n > 1:
        return [n]
    return []

Con esto, la prueba es válida. Intentemos con otro número primo, el 3.

def testPrimesOf2to3(self):
    self.assertEquals([2], factorsOf(2))
    self.assertEquals([3], factorsOf(3))

También es válida. Así que agregamos una nueva prueba, donde alimentamos un número con factores.

def testPrimesOf2to4(self):
        self.assertEquals([2], factorsOf(2))
        self.assertEquals([3], factorsOf(3))
        self.assertEquals([2,2], factorsOf(4))

Obtenemos un error con el mensaje: “AssertionError: Lists differ: [2, 2] != [4]”, ya que nuestra función factorsOf no está lista para arrojar el resultado esperado. Hora de modificar el código:

def factorsOf(n):

    result, factor = [], 2

    while n > 1:

        while n % factor == 0:

            result.append(factor)

            n /= factor

        factor += 1

    return result

Con esto ya pasamos todas las pruebas, así que ya tenemos listo nuestro código.

[Nota del editor: El código de este ejemplo está disponible en github en http://swgu.ru/38r3 y si consultas su historial de revisiones verás los mismos pasos que seguimos aquí.]

Obviamente he resumido el proceso un poco debido a limitaciones de espacio, pero creo que el proceso es claro. Como habrán visto, en ningún momento escribimos mucho código de una sola vez. ¡Y de eso se trata precisamente! Es mucho muy similar al proceso de aprendizaje previamente descrito, cuando probábamos nuestro código interactivamente en el intérprete. La retroalimentación constante nos motiva a seguir adelante con confianza y determinación, ya que sabemos que en todo momento nuestro sistema está funcionando. Incluso si introducimos un bug por error, podemos resolverlo con unos cuantos Ctrl-Z. Y eso es algo valioso.

Es común que para resolver un problema de programación nos basemos en código extraído de ejemplos u otros sistemas. Un ajuste aquí, otro allá hasta que aparentemente funciona. El problema es que no estamos seguros de lo que hicimos. Si el código falla después, no tenemos mucha idea de por qué. Al seguir de forma disciplinada TDD, entendemos si lo que estamos haciendo funciona o no, y por qué.

Escribiendo pruebas unitarias efectivas

En su libro The Art of Unit Testing [1], Roy Osherove 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". A continuación explico estas propiedades.

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 ésto consiste simplemente en darle un nombre apropiado. Si está probando una pila, por ejemplo, entonces no llamemos nuestras pruebas testStack_01, testStack_02, etcétera; este tipo de nombre son inútiles. Debemos 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).

Cuando una prueba lleva el nombre de una conducta observable, esta debe reflejar únicamente dicho aspecto del código. Es aceptable tener más de un assert dentro de una prueba, 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 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 le 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 (Factory) 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.

Que las pruebas tengan buena legibilidad también ayuda al mantenimiento. 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 pretende.

Rapidez
Una prueba unitaria efectiva debería ejecutarse en milisegundos. Una suite de pruebas puede llegar a contener cientos de pruebas, cada una enfocándose a un aspecto particular del código. Para minimizar la disrupción a nuestro flujo de trabajo, necesitamos que el conjunto de pruebas se ejecute en unos cuantos segundos. De no ser así, vamos a evitar ejecutarlas con la frecuencia necesaria, es decir, cada que hacemos un cambio; y 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. Volver al ciclo tradicional de modificar/compilar/debuguear destruye la motivación que habíamos construido. Si probar un cambio de una sola linea nos lleva 5 minutos de revisión en el depurador, nuestra motivación cae por 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. Esto permite ejecutar únicamente las pruebas para la clase o el sub-sistema que estamos probando en este momento, lo cual disminuye el tiempo requerido para ejecutar las pruebas.

Tips para el aprendizaje

A continuación comparto algunos tips que te ayudarán para el aprendizaje de TDD.

  • Escribe muchas pruebas, tantas como puedas. Familiarizate con el ritmo y las reglas de TDD.
  • Comienza con algo sencillo (¡pero no te detengas ahí!).
  • Cuando encuentres algo que no sabes como probar, apóyate en un compañero. Si no programas en parejas, consulta con un colega. Recolecta ideas de diversas fuentes.
  • Sé persistente y no te rindas. Si quieres obtener los frutos, debes primero poner el trabajo duro.
  • Conforme escribas más y más pruebas, comienza a organizarlas en suites y asegúrate que estas puedan ejecutarse de forma individual o colectiva, según sea necesario. ¡La organización también es una habilidad que hay que aprender!

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

Es recomendable probar una unidad de código solo a través de su API pública (y en términos prácticos, "protegido" es efectivamente público). Al hacer esto, obtenemos un mejor aislamiento de los detalles específicos de la implementación.

Evita colocar lógica en el código de prueba (if-then, switch/case, etc). Donde hay lógica, hay la probabilidad de introducir bugs, ¡y definitivamente no queremos bugs en nuestras pruebas!

Evita calcular el valor esperado, ya que podríamos terminar duplicando el código de producción, incluyendo cualquier error que este pudiera tener. Preferiblemente, calcula el resultado esperado manualmente (y revisalo por lo menos un par de veces) y colócalo como una constante.

Evita compartir estado entre pruebas. Debe ser posible ejecutar las pruebas en cualquier orden o incluso, ejecutar una prueba dentro de otra prueba. Mantener las pruebas aisladas de las demás también es un factor indispensable para la confiabilidad y mantenibilidad de las mismas.

Ninguna cantidad de comentarios puede sustituir un código claro. Si una prueba se convierte en un desastre, reescríbela.

Si no es posible determinar lo que una prueba está haciendo, es probable que en realidad esté verificando múltiples cosas: hazla pedazos y convierte cada uno en su propia prueba individual.

Frecuentemente los errores en el código de pruebas se esconden en los métodos de inicialización. Mantener este código simple y compacto puede ser un gran paso para la mantenibilidad del código.

Una unidad de código puede necesitar operar en circunstancias de escenarios variables. Esto puede llevar a que el código de inicialización se convierta rápidamente en un desastre. Crea fixtures o incluso casos de prueba especializados para cada escenario.

Nunca escatimes en claridad. Si es necesario, convierte cada escenario en una clase de prueba individual.

Si al probar una parte de tu código parece que requieres tener la mitad o más del sistema presente, verifica el alcance de la misma. ¿Estás probando una sola cosa?

Si una parte del código es particularmente resistente a tus esfuerzos de probarla, voltea al código en busca de problemas en el diseño del mismo. Un código fácil de probar frecuentemente está débilmente acoplado con el resto del sistema, es altamente cohesivo y sigue los principios fundamentales del diseño de software.

Conclusión

A lo largo de este artículo he compartido algunos conceptos clave de TDD así como tips para llevarlo a cabo exitosamente. Mi último tip sería que no dejes de aprender: lee libros, revistas, blogs, etcétera. Los proyectos de código abierto también son una excelente fuente de aprendizaje.

 

Referencias

[1] R. Osherove. The Art of Unit Testing. Manning Publications, 2009.

 

Bio

Alfredo Chavez (@alfredochv) comenzó a programar como hobby en 1989 con Basic y Pascal y profesionalmente desde 1993 con los lenguajes C y xBase. A principios de la década de 2000 descubre los métodos ágiles de desarrollo y desde entonces busca nuevas formas de aplicar sus prácticas en el trabajo del día a día. Se considera un autodidacta y un apasionado de la Programación Orientada a Objetos. Actualmente se interesa por enfoques que integren los principios y técnicas de la Programación Funcional y OO—algo así como una "Teoría de Campo Unificado de la Programación"