Introducción al Desarrollo Ágil: Test Driven Development (Respaldo del blog)


(ChileAgil) #1

Este es un artículo del viejo blog de chileagil, publicado el 19 noviembre de 2010, Publcado por @vkhemlan

Introducción

En Extreme Programming (XP) el Unit Testing es una práctica fundamental del ciclo de desarrollo al llevar a cabo un proyecto al permitir al equipo de desarrollo modificar y refactorizar el código existente sin miedo a romper funcionalidades, pues cada una de ellas tiene un test asociado que debe cumplirse y que, en principio, verifican la validez de toda la “superficie de contacto” de cada uno de los módulos que conforman el sistema.

Esta capacidad de adaptarse al cambio es fundamental en proyectos ágiles, donde el cambio no es percibido como algo peligroso o nocivo, sino intrínseco a un proyecto y como un factor que debe ser tomado en cuenta en todo el proceso de gestión y desarrollo.

A pesar de esto, el Unit Testing requiere de mucha disciplina para llevarse a cabo, pues para cada funcionalidad que se desee agregar se debe escribir, antes que nada, uno o varios test que comprueben que la funcionalidad está implementada correctamente. Esta necesidad de escribir las pruebas antes de la funcionalidad propiamente tal nos obliga a escribir pruebas que no dependen del código que hayamos escrito (pues aún no existe) volviéndonos jueces más imparciales a la hora de evaluar nuestro trabajo.

Finalmente, el estar forzados a escribir tests antes de implementar funcionalidades nos obliga a considerar con más detenimiento las responsabilidades y diseño de éstas, lo que generalmente se traduce en código más robusto y entendible para otros desarrolladores.

El ímpetu por agregar funcionalidades constantemente o por deadlines inminentes son fuertes tentaciones para no escribir unit tests, pero debemos ser capaces de racionalizar que el escribir previamente tests de unidad ya estamos subconscientemente definiendo el comportamiento de nuestro nuevo método o función sin preocuparnos del código particular detrás de él, lo que nos da claridad al momento de definirlo, además que escribir código de buena calidad en este momento nos ahorra dolores de cabeza al tener que extenderlo o mantenerlo a futuro.

En este artículo damos una pincelada inicial al desarrollo guiado por tests (Test Driven Development) con un ejemplo práctico, mediante el cual nos podemos desvincular de las particularidades del lenguaje de programación o del problema y concetrarnos en el flujo del desarrollo. En particular tampoco nos vamos a concentrar en el círculo de gestión de proyecto o de generación de valor para el cliente.

Nuestro ejemplo

En este ejemplo, y como parte de un sistema más grande, se nos solicita implementar un “stack”, una estructura de datos que representa una pila de objetos, en el que podemos insertar elementos (“push”) y sacar elementos (“pop”) uno a uno y en orden predefinido, en el que cada vez que realizamos un “pop” sacamos el último elemento que fue insertado al stack.

Para este caso definimos las siguientes funcionalidades:

  • Se deben poder crear stacks
  • Se debe poder insertar elementos en el stack mediante “push”
  • Se deben poder extraer elementos mediante “pop”
    • Si en este caso el stack está vacío, se debe gatillar un error o excepción
  • Se debe poder consultar el tamaño (número de elementos) de un stack en cualquier momento

Definición de la plataforma

Para llevar a cabo nuestro proyecto usaremos Python gracias a su flexibilidad y popularidad, además de requerir muy poco código inicial para definir funcionalidades.

Aquellos que no son familiares con Python no debieran tener mayores problemas siguiendo la guía pues el código es muy intuitivo, sólo se tiene que bajar el intérprete de Python si se ejecuta en Windows, pues en Linux y OS X viene incluido con el sistema operativo.

En principio podemos hacer unit testing escribiendo un programa común y corriente que llame los métodos de la clase que nos interesa y verifique manualmente sus resultados, pero dicha alternativa es engorrosa y de automatización no trivial. En vez de eso usaremos unittest, una suite de testing automatizado para Python inspirada en JUnit, por lo que su comportamiento debiera ser familiar para quienes han hecho testing antes.

Apegándonos a la convenciones, implementaremos el stack como una clase llamada Stack y definida en el archivo stack.py, los tests correspondientes a esta misma clase los definiremos en una clase llamada TestStack y definida en el archivo test_stack.py

Primera iteración

Test

En principio tenemos que escribir un test para cada una de las funcionalidades que vamos a implementar, inclusive el hecho de que nuestra clase Stack esté definida por nosotros, por lo que la primera versión de nuestro archivo test_stack.py es como sigue:
from stack import Stack
Para ejecutar nuestro test simplemente ejecutamos
python test_stack.py
De lo que obtenemos
Traceback (most recent call last):
  File "test_stack.py", line 1, in
    from stack import Stack
ImportError: No module named stack
¿Parece un test muy trivial? Imaginémosnos que Python ya define una clase Stack como parte del lenguaje y también en el paquete stack, en ese caso el test no habría arrojado ningún error y habríamos notado de inmediato que algo anda mal, ahorrándonos el proceso de cambiar el nombre de la clase para no tener colisiones con la definición de Python.

Lo único criticable del test es que debiera “reportar” que no hay una clase Stack disponible en vez de caerse, algo que se puede corregir, pero que preferimos no hacerlo pues complicaríamos innecesariamente los test posteriores.

Implementación

Para hacer que nuestro test “pase” necesitamos definir la clase Stack en el archivo correcto (stack.py)

from stack import Stack

Para ejecutar nuestro test simplemente ejecutamos

> python test_stack.py

de lo que obtenemos

Traceback (most recent call last):
  File "test_stack.py", line 1, in
    from stack import Stack
ImportError: No module named stack

¿Parece un test muy trivial? Imaginémosnos que Python ya define una clase Stack como parte del lenguaje y también en el paquete stack, en ese caso el test no habría arrojado ningún error y habríamos notado de inmediato que algo anda mal, ahorrándonos el proceso de cambiar el nombre de la clase para no tener colisiones con la definición de Python.

Lo único criticable del test es que debiera “reportar” que no hay una clase Stack disponible en vez de caerse, algo que se puede corregir, pero que preferimos no hacerlo pues complicaríamos innecesariamente los test posteriores.

Implementación

Para hacer que nuestro test “pase” necesitamos definir la clase Stack en el archivo correcto (stack.py)

class Stack:
    pass

Necesitamos agregar el “pass” dentro de la clase pues aún está vacía, algo que es inválido en Python, por lo que se usa esa palabra clave para que no lance errores al momento de ejecutarse.

Si ejecutamos nuevamente el test ejecuta sin decir nada, lo que nos indica que aprobamos el primer test.

Segunda iteración

En esta ocasión vamos a testear e implementación la creación de stacks

Test

En esta ocasión nuestro test es más concreto que el anterior y aprovecha la biblioteca unittest. Para usarla, cada una de nuestras clases que se dediquen a la ejecución de tests tiene que heredar de la clase unittest.TestCase.

Una vez creada la clase, definimos los tests, que son todos los métodos dentro de la clase cuyo nombre empiece con “test” y que son llamados automáticamente cuando se ejecuta el test.

import unittest
from stack import Stack

# Tests para la clase Stack
class TestStack(unittest.TestCase):
    # Test para verificar el constructor del stack
    def test_stack_creation(self):
        s = Stack()

if __name__ == '__main__':
    unittest.main()</pre>

Cuando se carga el test se define la clase TestStack y, si es que llamamos explícitamente el test (“python test_stack.py”) la condición al final del archivo (que está fuera de la clase) es cierta y se ejecuta unittest.main(), que carga y ejecuta todos los test definidos en la clase TestStack y muestra los resultados.

Como no hemos definido un constructor para Stack es natural que esperemos que nuestro nuevo test falle, pero al ejecutarlo nos damos cuenta que, si bien el test carga, no hay ningún error

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Esto se debe a que Python crea un constructor vacío para sus nuevas clases si estas no lo definen, así que, lamentablemente, tendremos que aceptar que el test no tuvo la oportunidad de “fallar”

Implementación

No hay cambios a la clase Stack

Tercera iteración

Ahora testearemos e implementaremos la inserción (“push”) y remoción (“pop”) de elementos del stack. Como en el test dependen una de la otra crearemos ambas pruebas simultaneamente, quizás no es muy estricto pero nos ahorra complicar innecesariamente el código.

Test

Crearemos un test que inserta algunos elementos y después los extrae, verificando que estén en el orden correcto. Además crearemos un segundo test que verifica que se lance un error o excepción si tratamos de quitar un elemento de un stack vacío.

Ejecutar estos tests generan dos errores, ambos apuntando a que aún no están definidos los métodos push y pop.

import unittest
from stack import Stack

# Tests para la clase Stack
class TestStack(unittest.TestCase):
    # Test para verificar el constructor del stack
    def test_stack_creation(self):
        s = Stack()

    # Test para verificar que la insercion y remocion
    # se hacen en el orden correcto
    def test_insert_and_remove_elements(self):
        s = Stack()
        s.push('a')
        s.push('b')
        s.push('c')

        self.assertEqual(s.pop(), 'c')
        self.assertEqual(s.pop(), 'b')
        self.assertEqual(s.pop(), 'a')

    # Test para verificar que la remocion de un stack
    # vacio gatilla una excepcion
    def test_exception_on_pop_empty_stack(self):
        s = Stack()
        self.assertRaises(Exception, s.pop)

if __name__ == '__main__':
    unittest.main()

Para probar la inserción y remoción de elementos en orden, usamos los métodos push para rellenar un stack para luego desapilarlos uno por uno, verificando que sean los que esperamos. En el segundo test inicializamos un stack vacío y tratamos de extraer un item, ante lo cual esperamos que la implementación arroje un error de tipo Exception, lo cual hace pasar el test.

Implementación

Para aprobar los tests, implementamos los métodos conflictivos

# Clase que implementa una lista LIFO
class Stack:
    # Constructor vacio
    def __init__(self):
        self.list = list()

    # Metodo que agrega un nuevo elemento al final de la lista
    def push(self, element):
        self.list.append(element)

    # Metodo que extrae el ultimo elemento de la lista y lo retorna
    def pop(self):
        try:
            return self.list.pop()
        except IndexError:
            raise Exception()

Si ejecutamos nuevamente los test estos aparecen OK

Cuarta iteración

Para terminar, implementamos un método size() que retorna el tamaño actual del stack sobre el que es llamado.

Test

Para esta funcionalidad definimos un único test, que verifica los cambios de tamaño en un stack a medida que insertamos y extraemos elementos.
import unittest
from stack import Stack

# Tests para la clase Stack
class TestStack(unittest.TestCase):
    # Test para verificar el constructor del stack
    def test_stack_creation(self):
        s = Stack()

    # Test para verificar que la insercion y remocion
    # se hacen en el orden correcto
    def test_insert_and_remove_elements(self):
        s = Stack()
        s.push('a')
        s.push('b')
        s.push('c')

        self.assertEqual(s.pop(), 'c')
        self.assertEqual(s.pop(), 'b')
        self.assertEqual(s.pop(), 'a')

    # Test para verificar que la remocion de un stack
    # vacio gatilla una excepcion
    def test_exception_on_pop_empty_stack(self):
        s = Stack()
        self.assertRaises(Exception, s.pop)

    # Test para verificar el tamano de un stack
    def test_stack_size(self):
        s = Stack()
        self.assertEqual(s.size(), 0)
        s.push('a')
        self.assertEqual(s.size(), 1)
        s.push('b')
        self.assertEqual(s.size(), 2)
        s.pop()
        self.assertEqual(s.size(), 1)

if __name__ == '__main__':
    unittest.main()

Como nos esperamos, el test falla porque el método size no está definido

Implementación

Para pasar este último test solo necesitamos definir el método size en nuestra clase

# Clase que implementa una lista LIFO
class Stack:
    # Constructor vacio
    def __init__(self):
        self.list = list()

    # Metodo que agrega un nuevo elemento al final de la lista
    def push(self, element):
        self.list.append(element)

    # Metodo que extrae el ultimo elemento de la lista y lo retorna
    def pop(self):
        try:
            return self.list.pop()
        except IndexError:
            raise Exception()

    # Metodo que retorna el tamano del stack
    def size(self):
        return len(self.list)