8 minutos
Test Driven Development en Python
Introducción
El Desarrollo Guiado por Pruebas o Test Driven Development (TDD) es una metodología de desarrollo donde primero se crean pruebas para nuestro código (métodos y funciones) y después se crea el código previamente dicho. La idea es definir en cierta medida el comportamiento que tendrán nuestras funciones determinados valores de entrada y comparar el valor devuelto con un valor esperado.
Módulos TDD en python
Actualmente los principales protagonistas son dos:
- unittest (PyUnit).
- pytest.
unittest
Está basado en JUnit (java) y en muchos casos trata de ser un clon del
mismo, por lo que hay mucho código repetitivo y en algunos casos te da
la impresión de estar codificando en java y no en python. También
existe [Nose] (https://nose.readthedocs.io/en/latest/) el cual corre
encima de unittest
pero con un enfoque más pythonico.
pytest
A diferencia de unittest
desde su creación se pensó en algo que
fuera 100% pythonico, por lo que trabajar con el resulta muy natural y
con mucho menos código de por medio. Cuenta con un enfoque totalmente
diferente a unittest
que a mi en lo personal se me hace muy intuitivo.
El proceso de prueba
El proceso de pruebas es continuo, es decir, se inicia desde el comienzo del proyecto y se mantiene durante toda la vida de este. Las fases de este proceso son:
- Definir una prueba que inicialmente no pasará el test.
- Crear el método o función a probar, con poca o nula funcionalidad con la finalidad que el test falle.
- Refactorizar el código del punto anterior para que el test pase la prueba.
- Repetir este proceso a lo largo del proyecto.
Una de las ventajas de este proceso es que al crear primero el test antes que la implementación del código, nos obliga a realizar un análisis a priori del problema (cosa que siempre deberíamos de hacer). El seguir este proceso religiosamente es bastante tedioso, ya que crear una prueba para algo que aun no existe, o probar una función vacía, al final es una pérdida de tiempo qu se puede usar para crear más pruebas. Lo importante es encontrar un balance entre crear las pruebas e ir codificando.
¿Qué debo de probar?
Esto depende de tu caso de uso, ya que los componentes de un backend django son diferentes a los de una aplicación para la línea de comandos. Dicho esto, teniendo en cuenta las diferencias correspondientes ¿Debo de probar cada función o método de mi proyecto? Hay dos tendencias en este sentido, las cuales veremos brevemente.
Probar todo
Esto sería lo más recomendable, ya que aseguramos que todo nuestro código está verificado y cualquier refactorización que hagamos, o algún cambio en la versión de una librería no va a romper ninguna funcionalidad. Por poner un pero, esto requiere un mayor tiempo de desarrollo al crear más pruebas.
Probar las interfaces públicas
Otra de las escuelas de TDD es probar únicamente los métodos y funciones públicos, ya que estos engloban las llamadas a varios métodos y si el último valor regresado no es el deseado, ya sabemos que algo está fallando. Aunque aquí tenemos menos pruebas y podemos decir que ganamos en tiempo, cuando algo falla no sabemos bien el por que.
Crear tests cuando aparece un bug
Cuando aparece un bug tenemos una oportunidad muy valiosa para crear un test. Al hacerlo tenemos dos grandes beneficios:
- Asegurar que encontramos donde radica el bug.
- Validar que el fix que hayamos realizado funcione.
- Evita regresiones, ya que al refactorizar más adelante si el bug se replica lo podemos cachar antes de llegar a producción.
El proyecto de ejemplo
En lo personal me costó trabajo adentrarme en el TDD por que no encontré ejemplos prácticos o de la vida real, entonces trasladar lo aprendido a uno de mis proyectos era bastante complicado. Teniendo esto en mente, usaré dos proyectos reales (aunque simplificados por cuestiones prácticas) para explicar como implementar TDD.
Palette
Es un pequeño módulo para trabajar con colores HTML. La idea es tener una paleta de colores fija para dar identidad a un proyecto, pero en el caso de que necesites más colores de los existentes en la paleta, se generará un color aleatorio.
storemap
Es el frontend para la aplicación web del mismo nombre, la cual registra tiendas de una ciudad para representarlas en un mapa y hacer búsqueda de las mismas.
Creando los tests para Palette
Para la implementación de nuestra paleta de colores usaremos el módulo
enum
para almacenar los colores básicos de nuestra aplicación, y
agregaremos los métodos necesarios para obtener los colores o
generarlos aleatoriamente. La estructura de nuestro proyecto es:
Estructura de archivos
palette
|--->palette.py
|--->test_unittest.py
|--->test_pytest.py
En el archivo palette.py
está la implementación de nuestro módulo,
en test_unittest.py
las pruebas para unittest
y en
test_pytest.py
las pruebas para pytest
. Para este ejemplo usaremos
tanto unittest
como pytest
para tener una comparación de ambos,
pero en el resto del tutorial usaré únicamente pytest
, ya que a mi
parecer es mucho más práctico y expresivo.
Creando las pruebas
Siguendo los puntos del proceso de prueba, empezaremos creando un test para validar un color HTML:
import unittest
import re
from palette import Palette
class TestColorValid(unittest.TestCase):
def validated_color(self, color):
"""Use a regex to match a HTML color (#ffffff).
Args:
color (string): the string to validate
Returns:
a match object if valid, None otherwise.
"""
regex = '^#[0-9a-fA-F]{6}$'
return re.match(regex, color)
def test_palette_get_color_by_index_integer_type(self):
with self.assertRaises(TypeError) as context:
Palette.get_color_by_index('some string')
self.assertTrue("isn't an integer" in str(context.exception))
import enum import Enum, unique
class Preferred(Enum):
PRIMARY = '#fc9a46'
SECONDARY = '#5091c1'
@unique
Palette(Enum):
ONE = Preferred.PRIMARY.value
TWO = Preferred.SECONDARY.value
def __init__(self, color):
self.color = color
super().__init__()
En este punto, si corremos nuestro test con python -m unittest test_unittest.py
obtendremos lo siguiente:
E
======================================================================
ERROR: test_palette_get_color_by_index_integer_type (test_unittest.TestColorValid)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/mtellez/proyectos/palette/test_unittest.py", line 22, in test_palette_get_color_by_index_integer_type
Palette.get_color_by_index('some string')
File "/home/mtellez/.virtualenvs/jmad_env/lib/python3.7/enum.py", line 349, in __getattr__
raise AttributeError(name) from None
AttributeError: get_color_by_index
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
¡Nuestro test tiene un error!
Vamos a explicar el reporte de nuestros tests:
- En la línea número 1 tenemos una
E
, lo cual nos indica que se produjo un error. Si no hubo error pero el test falló, entonces tendríamos unaF
. Si el test hubiera pasado, en lugar de laE
tendríamos un punto (hasta ahorita tenemos un solo test, pero podemos tener algo como..F.E
, lo cual indica que los dos primeros test pasaron, el tercero falló, el cuarto pasó y el quinto tuvo un error). - En la línea 3 nos dice que test falló, en este caso fue
test_palette_get_color_by_index_integer_type
. - La línea 10 nos indica que la clase
Palette
no tiene el métodoget_color_by_index
.
Ahora que ya sabemos que nos falta un método, hay que crearlo (de ahora en adelante iré añadiendo código pero sin reescribir lo anterior por cuestiones de espacio):
def get_color_by_index(color_index):
if not isinstance(color_index, int):
raise TypeError(
f"The color_index '{color_index}' isn't an integer.")
Ahora volvemos a correr nuestro ejemplo:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Ahora nuestró test pasó la prueba. Ya tenemos la confianza que si le
pasamos un parámetro que no sea un entero a get_color_by_index
se
generará una excepción de tipo TypeError
con la cadena “isn’t an
integer”.
es buena práctica revisar el mensaje de la excepción, por que puedes estar cachando una excepción del mismo tipo pero no por el motivo que esperabas.
Ahora necesitamos validar que nos regrese un código de color válido. En el requerimiento inicial tenemos definido dos escenarios: a) Si le pasamos un entero que esté dentro de la paleta nos regresará el color asignado a ese entero, b) pero si es mayor, se generará un color aleatorio. ¿Y si se pasa un entero negativo? Vamos a crear los tests para estos tres casos.
def test_palette_get_color_by_index_negative_index(self):
with self.assertRaises(ValueError) as context:
Palette.get_color_by_index(-1)
self.assertTrue("must be positive" in str(context.exception))
def test_palette_get_random_color_valid(self):
color = Palette.get_random_color()
self.assertTrue(self.validated_color(color))
def test_palette_get_color_by_index_in_palette(self):
colors = list(Palette)
color0 = Palette.get_color_by_index(0)
color1 = Palette.get_color_by_index(1)
self.assertEqual(colors[0].value, color0)
self.assertEqual(colors[1].value, color1)
def test_palette_get_color_by_index_not_in_palette(self):
color_index = len(Palette) + 4 # Aseguramos que no exista el color
color = Palette.get_color_by_index(color_index)
self.assertTrue(self.validated_color(color))
Nuestro módulo palette.py
quedó así:
from enum import Enum, unique
import random
class Preferred(Enum):
PRIMARY = '#fc9a46'
SECONDARY = '#5091c1'
@unique
class Palette(Enum):
ONE = Preferred.PRIMARY.value
TWO = Preferred.SECONDARY.value
def __init__(self, color):
self.color = color
super().__init__()
def get_random_color():
color = "#" + ''.join(
[random.choice('0123456789ABCDEF') for j in range(6)])
return color
def get_color_by_index(color_index):
if not isinstance(color_index, int):
raise TypeError(
f"The color_index '{color_index}' isn't an integer.")
if color_index < 0:
raise ValueError(f'The index "{color_index} must be positive.')
if color_index >= len(Palette):
return Palette.get_random_color()
return Palette.items[color_index].value
Palette.items = tuple(Palette)
Cuando corremos la batería de tests, obtenemos lo siguiente:
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
Todos nuestros tests pasaron.
Ahora pondré los mismo tests pero usando pytest
para mostrar las
diferencias entre ambos módulos.
import pytest
import re
from palette import Palette
def validated_color(color):
regex = '^#[0-9a-fA-F]{6}$'
return re.match(regex, color)
def test_palette_get_random_color_valid():
color = Palette.get_random_color()
assert validated_color(color)
def test_palette_get_color_by_index_integer_type():
with pytest.raises(TypeError, match="isn't an integer"):
Palette.get_color_by_index('some string')
def test_palette_get_color_by_index_negative_index():
with pytest.raises(ValueError, match='must be positive'):
Palette.get_color_by_index(-1)
@pytest.mark.parametrize('color_index', (0, 1))
def test_palette_get_color_by_index_in_palette(color_index):
color = Palette.get_color_by_index(color_index)
colors = list(Palette)
assert colors[color_index].value == color
def test_palette_get_color_by_index_not_in_palette():
color = Palette.get_color_by_index(len(Palette) + 4)
assert validated_color(color)
<!-- endtab -->
La primera diferencia que salta a la vista, es que unittest
usa
clases y métodos, mientras que pytest
solo usa funciones. Otra gran
diferencia es que unittest
usa self.assertEqual(foo, bar)
,
self.asserTrue(foo)
, self.assertGreater(foo, bar)
, etc., mientras
que en pytest
solo usamos assert
. Si quisieramos hacer un
comparativo con los tres assert de este párrafo tendríamos:
assert foo == bar
assert foo
assert foo > bar
como podemos ver, la sintaxis es mucho más natural y no tenemos que
recordar un montón del self.assertAlgo
. pytest
tiene funciones muy
interesantes como los fixture y los tests parametrizados
(de hecho
el test test_palette_got_color_by_index_in_palette
está
parametrizado) que hace muy sencillo y hasta divertido escribir
tests. En el siguiente ejemplo usaremos únicamente pytest
y
exploraremos estas funciones.