MagMax Blog

Aviso: En este blog puede encontrar código!

Python: Cómo Hacer Pruebas 4: pyDoubles

| Comments

Tras las peticiones populares, no puedo evitar escribir este post antes de lo que esperaba.

Es la continuación del artículo Python: Cómo hacer pruebas, que parece haber tenido bastante éxito.

En este caso vamos a ver los dobles de prueba, y utilizaremos, cómo no, pyDoubles, creado principalmente por Carlos Blé y disponible en Debian gracias a mi buen amigo David Villa.

Actualización 2011-11-16: Corrijo el significado de las siglas F.I.R.S.T. (¡Gracias, David!).

Qué vamos a hacer

En las ocasiones anteriores me he centrado en pruebas unitarias de objetos que no tienen relación con el mundo exterior. Esto está muy bien, pero es una mala programación. En una programación más pythónica utilizaremos objetos. Montones de objetos. Y los objetos hablan entre ellos.

Una batería de pruebas unitarias que se precie tiene que cumplir las condiciones FIRST, a saber:

  • Fast, deben ejecutarse en unos pocos segundos.
  • Independant, evitando el scripting: una prueba es independiente de todas las anteriores y, por tanto, pueden ejecutarse en cualquier orden.
  • Repeatable, de manera que podemos ejecutarla dos o más veces seguidas sin que afecte al resultado.
  • Self-Validating, dando un valor booleano por respuesta, y evitando así buscar esta respuesta en un mar de logs.
  • Timely, en el momento adecuado; de nada sirve escribir los tests después del código, ya que éste puede resultar difícil de probar.

Podéis consultar la definición original de estas reglas en el libro “Clean Code”, de Robert C. Martin, al final del capítulo 9, “Unit Tests”, páginas 130-131.

Supongamos por un momento que tenemos un objeto que depende de otro. ¿Cómo hacemos que sean las pruebas del primero sean independientes? Lo tenemos complicado. Bueno, pues aquí es donde entran los dobles de pruebas: Creamos un objeto que parece el original, pero que no es el original (bueno, o sí… ya lo veremos).

Puedo aseguraros que me ha resultado difícil encontrar un ejemplo en el que relacionar dos objetos, con un dominio conocido por todos, pequeño y sencillo, de manera que resulte altamente didáctico… Y resulta que tenía el caso justo enfrente de mí :D. Vamos a implementar un pequeño sistema de ficheros muy muy básico. Vamos a centrarnos en un directorio. Y nos vamos a centrar en dos operaciones: borrar y cambiar permisos. Claro… Tenemos el problema de que un directorio depende fuertemente de otros directorios y de archivos, pero no quiero meterme en la implementación de los archivos… ¿Cómo lo hacemos?

Archivos

Como he dicho, no queremos meternos en la implementación de archivos, así que esto es lo que vamos a necesitar (lo copio aquí para no estar repitiéndolo todo el rato):

class Archivo:
def borrar(self):
raise NotImplementedError("no lo vamos a necesitar")
def cambiar_permisos(self, permisos):
raise NotImplementedError("no lo vamos a necesitar")

Como véis, si llegáramos a entrar en alguna de las funciones, nuestros tests fallarían estrepitosamente. Tenemos que ser capaces de probarlo sin usar el archivo real.

Las Pruebas…

Para probarlo voy a usar nose, ¡¡que para eso expliqué en el artículo anterior cómo usarlo!!.

El problema de nose es que la clase de pruebas debe estar arriba del todo. Tenedlo en cuenta a la hora de ir componiendo los tests…

Directorios

Asumamos que nuestro código es legacy y ya teníamos las clases escritas… Así me ahorro bastante tiempo XD. Ahí va la clase Directorio:

class Directorio:
def init(self):
self.contenido = []
def borrar(self):
for x in self.contenido:
x.borrar()
def anadir(self, objeto):
self.contenido.append(objeto)
def cambiar_permisos(self, permisos):
for x in self.contenido:
x.cambiar_permisos(permisos)

Probando, que es gerundio

Añadiendo

Pues ya tenemos nuestros Directorios, (no) tenemos nuestros Archivos… ¡¡Vamos a probarlo!! Voy a necesitar una prueba de la que no os he hablado, y es que para probar que borramos, necesitamos añadir. ¿Estará funcionando correctamente este método? (nuevamente recuerdo que los tests deben ir al principio del archivo para que nose funcione):

import unittest

from pyDoubles.framework import *

class testSeBorrarDirectorios (unittest.TestCase):
def test_probamos_que_anadimos(self):
archivo = stub(Archivo())
sut = Directorio()

sut.anadir(archivo) self.assertTrue (archivo in sut.contenido)

¿Qué ha ocurrido aquí? ¿Qué es todo eso?

Bueno, es más sencillo de lo que parece. Lo que hacemos es usar pyDoubles, y por eso importamos todo lo que hay en el framework. Además, creamos una clase que hereda de TestCase para que se ejecuten las pruebas, y será en esta clase donde meteremos todos los tests.

El primero de los tests crea un archivo que no es un archivo… Es un “stub”. ¿Qué es eso?

Un STUB es un objeto que se comporta como el original, pero no es el original. Puede reemplazar un único método o un conjunto de ellos.

Como véis, lo que sea “archivo” no importa, ya que sólo me importa comprobar que, después, puedo recuperarlo. Técnicamente, y aprovechando la potencia de Python podría haber puesto cualquier cosa, pero usar un objeto pseudo-real es más claro. Si no me creéis, podéis comprobar que es un objeto de tipo Archivo.

Pero veamos el segundo ejemplo, que es más claro.

Borrando

En este caso vamos a borrar. Evidentemente, se tratará de un borrado recursivo, ya que si no pierde la gracia.

def test_probamos_que_borramos(self):
archivo = spy(Archivo())
sut = Directorio()
sut.anadir(archivo)
sut.borrar()
assert_that_method(archivo.borrar).was_called()

Aquí no he usado un stub, sino un spy, un espía.

Los SPY o spies son dobles de prueba como los stub, solo que tienen post-condiciones. Es decir: se crean, se usan y después se comprueba que se usaron correctamente.

Precisamente por eso está la comprobación final, en la que compruebo que el método “borrar” del objeto “archivo” se llamó. ¿Qué hizo? Pues nada, porque era un objeto de coña :D

Una de las grandes ventajas de pyDoubles es lo semántico que resulta su uso. ¡¡Casi se puede leer!!

Cambiando permisos

Ahora vamos con la segunda parte: comprobar que el cambio de permisos es recursivo. Es igual que el borrado, con la diferencia de que tenemos que pasar un parámetro:

def test_cambiar_permisos_llama_con_valores_adecuados(self):
archivo = spy(Archivo())
sut = Directorio()
sut.anadir(archivo)
sut.cambiar_permisos(0777)
assert_that_method(archivo.cambiar_permisos).was_called().with_args(0777)
assert_that_method(archivo.borrar).was_never_called()

Como podemos observar, en este caso hemos vuelto a utilizar un espía. Aquí comprobamos que el método se llamó con los permisos indicados, y también que el método borrar no se llamó nunca. Esto no es ni necesario, pero lo he puesto por mostrar más capacidades de pyDoubles.

Lo mismo se podría hacer de otra manera ligeramente diferente:

def test_cambiar_permisos_con_mock(self):
archivo = mock(Archivo())
expect_call(archivo.cambiar_permisos).with_args(0777)
sut = Directorio()
sut.anadir(archivo)
sut.cambiar_permisos(0777)
archivo.assert_that_is_satisfied()

Aquí he utilizado un mock. Un mock es un stub con precondiciones. Es decir: primero digo lo que va a pasar, ejecuto, y compruebo que ha pasado.

Tan solo nos quedaría ver los “proxyspy”, que son espías que llaman al objeto real.

Todo junto

Aquí tenéis todo el código de un tirón:

import unittest

from pyDoubles.framework import *

class testSeBorrarDirectorios (unittest.TestCase):
def test_probamos_que_anadimos(self):
archivo = stub(Archivo())
sut = Directorio()

sut.anadir(archivo) self.assertTrue (archivo in sut.contenido) def test_probamos_que_borramos(self): archivo = spy(Archivo()) sut = Directorio() sut.anadir(archivo) sut.borrar() assert_that_method(archivo.borrar).was_called() def test_cambiar_permisos_llama_con_valores_adecuados(self): archivo = spy(Archivo()) sut = Directorio() sut.anadir(archivo) sut.cambiar_permisos(0777) assert_that_method(archivo.cambiar_permisos).was_called().with_args(0777) assert_that_method(archivo.borrar).was_never_called() def test_cambiar_permisos_con_mock(self): archivo = mock(Archivo()) expect_call(archivo.cambiar_permisos).with_args(0777) sut = Directorio() sut.anadir(archivo) sut.cambiar_permisos(0777) archivo.assert_that_is_satisfied()

class Archivo:
def borrar(self):
raise NotImplementedError("no lo vamos a necesitar")

def cambiar_permisos(self, permisos): raise NotImplementedError("no lo vamos a necesitar")

class Directorio:
def init(self):
self.contenido = []

def borrar(self): for x in self.contenido: x.borrar() def anadir(self, objeto): self.contenido.append(objeto) def cambiar_permisos(self, permisos): for x in self.contenido: x.cambiar_permisos(permisos)

Respuestas predefinidas

Habrá casos en los que necesitemos una respuesta determinada cuando se llame a un método, de manera que el algoritmo que estamos probando pueda continuar. Para ello podemos “prediseñar” métodos; ahí van diferentes maneras diferentes de hacerlo:

objeto.metodo = method_returning(9)
objeto.metodo = method_raising(NotImplementedError)
when(objeto.metodo).then_return(9)
when(objeto.metodo).with_args(5).then_return(25)

Cuestión de nomenclatura

La diferencia entre un stub, mock y spy es puramente teórica. Hay muchos frameworks que los confunden o ignoran. Yo, personalmente, utilizo siempre spies, ya que me parecen más intuitivos, aunque suelo llamarlos mocks, término con el que se suelen denominar globalmente a todos los dobles de prueba.

Hay más dobles de prueba, como los Fake, que son implementaciones completas de una interfaz pero con funcionalidad simplificada (imaginad una clase de acceso a base de datos que siempre devuelve datos fijos, sin acceder realmente). Si lo pensáis, un stub no es más que un fake automático XD. ¡¡Todos están relacionados!!

Así que, resumiendo:

  • Es un stub si no se realizan comprobaciones.
  • Será un mock si tenemos precondiciones.
  • Un spy si tenemos postcondiciones.
  • Un fake si usamos un objeto que implementa la funcionalidad simplificada.

Diré una cosa de corazón: llamadlos como queráis, pero no los mezcléis. La mezcla da lugar a métodos más complejos. Podéis usar distintos tipos en distintas pruebas, pero en una misma prueba, no los mezcléis. Y mucho menos con un único objeto.

Hamcrest y pyDoubles

Resulta que escribí el artículo anterior sabiendo lo que me hacía, y que podemos combinar los matchers de Hamcrest junto a pyDoubles… ¿Cómo? Pues, por ejemplo, en el método with_args:

when(objeto.metodo).with_args(instance_of(objeto)).then_return('hola, mundo')
when(objeto.metodo).with_args(any_of(starts_with('hola'), ends_with('mundo')).then_return('hola, mundo')

Como véis, hemos usado instance_of, any_of, starts_with y ends_with, que son matchers de Hamcrest.

Un gran poder conlleva una gran responsabilidad

… como le dijo el Tío Ben a Peter Parker :D

Tenemos en nuestras manos una gran herramienta, pero debemos tener en cuenta que debemos usarlo con cuidado, de la misma manera que un mecánico no arregla todas las averías con el mismo martillo.

Hay ciertas reglas básicas que cumplir, con el fin de evitar terminar con pruebas terriblemente complejas o nos metamos en camisas de once varas.

  • Nunca “mockeéis” un objeto que no sea vuestro. De esta manera se evitan mocks interminables de todo Hibernate o cosas incluso peores. Es un fallo habitual en todo novato, y yo no fui la excepción.
  • Realizad mocks precisos. Cuantas menos cosas reemplacéis, más estable será el test (en la terminología típica, “los tests serán menos frágiles”). Si estáis reemplazando muchos métodos, a lo mejor necesitáis un fake.

Para más información

Podéis visitar la web de pyDoubles, la de Hamcrest, la de nose, … O releer mis antiguos posts:

No sé si me he dejado algo en el tintero. Tengo la sensación de que sí… sobre todo en las reglas a cumplir cuando usamos mocks. Toda contribución será bienvenida.

Un saludo!!

Comments