Desarrollo guiado por pruebas (TDD) en Python

El desarrollo guiado por pruebas (TDD por sus siglas en inglés test-driven development) es una práctica de programación que consiste en crear primero los test que probaran la consistencia de los estados o de las transiciones de estados del objeto que se pretende desarrollar, para luego programar estas funcionalidades.

La idea es que estas pruebas sirvan como definición de requisitos, los cuales estaremos obligados a cumplir.

El procedimiento general para un TDD es el siguinete:

  1. Para cada estado o transición de estados, seguir estos pasos:
    1. Crear un test para un estado o para una transición de estado.
    2. Correr el test y esperar que falle.
    3. Generar el mínimo código que logre aprobar el test.
  2. Mejorar el código generado de manera que quede más limpio claro y preciso, procurando siempre mantener la funcionalidad.

 TDD en Python

Veremos ahora como realizar un desarrollo guiado por pruebas en Python versión 2.7.2.

Usaremos de ejemplo el modelamiento de una persona. Diremos que una persona es simplemente un nombre, un apellido y una fecha de nacimiento.

Entonces lo primero que debemos hacer, es crear un test. Partiremos poniendo a prueba una nueva instancia de una persona, para esto creamos crearemos el fichero que contendrá todas las pruebas que realizaremos al objeto Persona y lo llamaremos “testPerson.py”. En el haremos nuestra primera prueba antes mencionada, para esto escribiremos lo siguiente:

import unittest

class TestPerson(unittest.TestCase):
    def testCreate(self):
        # Arrange
        # Act
        p = Person("Juan", "Perez", "1983-06-13")
        # Assert
        self.assertNotEqual(None, p)

Vamos viendo las partes que componen este código. Lo primero a lo que debemos poner atención es a la importación de la librería “unittest”, esta nos proveerá de una serie de herramientas que nos permitirá poner a prueba nuestros objetos, iremos viendo algunas de estas herramientas en la medida que vayamos generando nuestros distintos test.

Lo segundo a lo que hay que poner atención, es al hecho de que nuestra clase en la que haremos las pruebas, extiende de la clase “TestCase” de “unittest”. El nombre de nuestra clase no es relevante a la hora de evaluar nuestro objeto, pudiendo ser cualquier otro (la idea es que sea lo más descriptivo posible).

Lo último importante es el assert. Es finalmente en esta parte donde se evalúa si el objeto se comporta como se espera o si está fallando. En este caso hacemos un “self.assertNotEqual(None, p)”, lo que quiere decir es que la condición a evaluar es que el objeto “p” sea distinto de “None”, con lo que podremos determinar si creó correctamente la instancia de la clase Person o no.

Por un tema de orden, el código de dividió en tres partes: “Arrange”, “Act” y “Assert” que simbolizan preparar el ambiente de pruebas, ejecutar las acciones a probar y evaluar si se pasan o no las pruebas.

Para realizar las pruebas, debemos dirigirnos a la consola del sistema, acceder al directorio que contiene nuestro archivo “testperson.py” y ejecutar el siguiente comando:

python -m unittest discover -v

Que significa :

  • python: ejecutar la aplicación de python
  • -m unittest: con el módulo unittest para ejecutar las priebas
    • discover: para que busque en el directorio todos los “TestCase”
  • -v: de manera verbosa, para que nos muestre el detalle del procedimiento

Lo que nos entrega como resultado:

testCreate (testperson.TestPerson) ... ERROR

======================================================================
ERROR: testCreate (testperson.TestPerson)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/camello/Person/src/testperson.py", line 7, in testCreate
    p = Person("Juan", "Perez", "1983-06-13")
NameError: global name 'Person' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Este error era de esperarse, puesto que nunca hemos creado la clase “Person”. Por lo tanto nuestro primer intento es crear la clase “Person” y volver a correr el test. Para eso creamos el fichero “person.py” con las siguientes líneas.

class Person(object):
    pass

Importamos la clase “Person” en nuestra clase de pruebas:

from person import Person
import unittest

class TestPerson(unittest.TestCase):
    ...

Volvemos a correr el test:

testCreate (testperson.TestPerson) ... ERROR

======================================================================
ERROR: testCreate (testperson.TestPerson)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/camello/Person/src/testperson.py", line 8, in testCreate
    p = Person("Juan", "Perez", "1983-06-13")
TypeError: object.__new__() takes no parameters

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Ahora el error es distinto y tiene que ver con el hecho de que no existe un constructor para la clase “Person” que reciba esos tres parámetros. Por lo tanto la solución más simple podría ser crear el constructor que reciba dichos parámetros, pero que no haga nada.

class Person(object):
    def __init__(self, name, latsName, birth):
        pass

Corremos el test…

testCreate (testperson.TestPerson) ... ok

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

OK

Y ahora si lo pasamos sin problemas.

Hemos pasado el primer test, hora buscaremos un segundo estado a evaluar. Por ejemplo, recuperar el nombre de una persona recién creada. Un ejemplo de como hacer esto, sería agregar la prueba “testGetName” como sigue:

from person import Person
import unittest

class TestPerson(unittest.TestCase):
    def testCreate(self):
        # Arrange
        # Act
        p = Person("Juan", "Perez", "1983-06-13")
        # Assert
        self.assertNotEqual(None, p)
        
    def testGetName(self):
        # Arrange
        p = Person("Juan", "Perez", "1983-06-13")
        # Act
        name = p.getName()
        # Assert
        self.assertEqual("Juan", name)

Corremos los test…

testCreate (testperson.TestPerson) ... ok
testGetName (testperson.TestPerson) ... ERROR

======================================================================
ERROR: testGetName (testperson.TestPerson)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/camello/Person/src/testperson.py", line 16, in testGetName
    name = p.getName()
AttributeError: 'Person' object has no attribute 'getName'

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (errors=1)

Vemos que testCreate sigue aprobando la evaluación sin problemas, pero testGetName falla por la simple razón de que el método “getName” no está definido en la clase “Person”. Por lo tanto, la solución más simple es crearlo:

class Person(object):
    def __init__(self, name, latsName, birth):
        pass

    def getName(self):
        pass

Corremos los test..

testCreate (testperson.TestPerson) ... ok
testGetName (testperson.TestPerson) ... FAIL

======================================================================
FAIL: testGetName (testperson.TestPerson)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/camello/Person/src/testperson.py", line 18, in testGetName
    self.assertEqual("Juan", name)
AssertionError: 'Juan' != None

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

Ahora “testGetName” falla porque “getName” no está retornando el valor esperado, es entonces cuando hacemos cambios en el código de manera que cumpla con requerido. Para esto guardaremos en una variable de la clase “Person” el nombre de la persona a la hora de crear el objeto y lo retornaremos cada vez que se llame al método “getName”.

class Person(object):
    def __init__(self, name, latsName, birth):
        self.name = name

    def getName(self):
        return self.name

Volvemos a correr los test…

testCreate (testperson.TestPerson) ... ok
testGetName (testperson.TestPerson) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Si seguimos estos mismo pasos para la evaluación de la recuperación del apellido y la fecha de nacimiento de la persona, rápidamente obtendremos los siguientes resultados para nuestros test y para la clase “Person” respectivamente.

from person import Person
import unittest

class TestPerson(unittest.TestCase):
    def testCreate(self):
        ...

    def testGetName(self):
        ...

    def testGetLastName(self):
        # Arrange
        p = Person("Juan", "Perez", "1983-06-13")
        # Act
        lastName = p.getLastName()
        # Assert
        self.assertEqual("Perez", lastName)

    def testGetBirth(self):
        # Arrange
        p = Person("Juan", "Perez", "1983-06-13")
        # Act
        birth = p.getBirth()
        # Assert
        self.assertEqual("1983-06-13", birth)
class Person(object):
    def __init__(self, name, lastName, birth):
        self.name = name
        self.lastName = lastName
        self.birth = birth

    def getName(self):
        return self.name

    def getLastName(self):
        return self.lastName

    def getBirth(self):
        return self.birth

Obteniendo resultados satisfactorios en todos:

testCreate (testperson.TestPerson) ... ok
testGetBirth (testperson.TestPerson) ... ok
testGetLastName (testperson.TestPerson) ... ok
testGetName (testperson.TestPerson) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

Procediendo de la misma manera, podríamos agregar nuevas funcionalidades a las clase “Person”, como por ejemplo un método que retorne la edad de la persona, partiendo siempre con la creación del test que verifique la propiedad.