Testing y Mocking en Python

Enviado por Jaume Moral en Jue, 15/01/2015 - 11:44

Test unitarios, de integración, regresión, aceptación, comportamiento, TDD ... Hay muchos tipos de test y técnicas de testing y muchas veces la frontera entre ellas no es tan clara como quisiéramos.

En este artículo no intentaremos explicar ninguna de estas técnicas o filosofías. Iremos a un caso concreto y claro: queremos probar una funcionalidad de nuestro programa que depende de otra sobre la que no tenemos control. Y queremos poder hacerlo de una forma automatizada y reproducible utilizando las herramientas que nos proporciona el lenguaje Python.

Mucho mejor con un ejemplo

Nuestro ejemplo es muy simple: tenemos una clase que nos permite enviar notificaciones por mail a una lista de usuarios identificados por su nombre de usuario. Esta dependerá de una clase que devuelve información sobre los usuarios (en principio a partir de una base de datos, aunque en nuestro ejemplo lo hemos simplificado) y otra clase que permite enviar mails.

 

 

users.py

Esta es la clase que debería consultar la base de datos para obtener la información, pero como no es el objetivo del artículo, haremos que tenga esta funcionalidad tan simple.

class UserRepository:
    def get_user(self,username):
        return {"username:":username,"mail":username+"@upc.edu"}


mailer.py

import smtplib
from email.mime.text import MIMEText

sender="reply@fib.upc.edu"

class Mailer:
    def send_mail(self,mail,subject,body):
        msg = MIMEText(body)
        msg['Subject'] = subject
        msg['From'] = sender
        msg['To'] = mail

        s = smtplib.SMTP('localhost')
        s.sendmail(sender, [mail], msg.as_string())
        s.quit()

notifier.py

from users import UserRepository
from mailer import Mailer
import sys

class Notifier:
    def __init__(self,user_repository=UserRepository(),mailer=Mailer()):
        self.user_repository=user_repository
        self.mailer=mailer

    def notify(self,message,usernames):
        for username in usernames:
            user=self.user_repository.get_user(username)
            mail=user['mail']
            self.mailer.send_mail(mail,message,message)

if __name__ == "__main__":
    notifier=Notifier()
    notifier.notify (sys.argv[1],sys.argv[1:])

Si queremos podemos ejecutar el programa pasando el mensaje y la lista de usernames como parámetros

$python notifier.py "Agafa les claus de casa" jaume.moral albert.obiols

Dado este sistema, como podemos hacer pruebas que realmente se envíen mails correctamente ... sin que realmente se envíen? Como podemos simular que el UserRepository nos devuelva un cierto valor para poder probar nuestro código? Y sobre todo y no menos importante ... como poder hacer esto de una forma fácil?

Antes de continuar, debemos decir que ya de entrada hemos tomado una decisión de diseño que nos ayudará a escribir pruebas por nuestro código: hemos creado la clase Notifier de forma que le podamos "inyectar" las dependencias, es decir, pasarle por parámetro en el momento de la creación los objetos con los que se deberá comunicar, en vez de crearlos ella misma. Esto nos facilita la sustitución de estas dependencias durante las pruebas.

Pruebas unitarios con "unittest"

Unittest es un paquete estándar de Python que permite, como su nombre indica, hacer pruebas unitarios. La forma de usarlo es parecido al JUnit de Java, y básicamente consiste en

  •      Hacer una clase que extienda unittest.TestCase
  •      Añadir métodos que empiecen por "test_" con la implementación de las pruebas
  •      Hacer aserciones para comprobar que el resultado es el esperado

Para empezar, haremos una pequeña prueba para testear la implementación "de mentira" que hemos hecho de nuestro servicio de usuarios. Sabemos que si vamos a buscar el usuario "jaume.moral" el mail que nos debería devolver se "jaume.moral@upc.edu". Escribiremos un test para codificar este comportamiento.

import unittest
from users import UserRepository

class NotificationsTestCase(unittest.TestCase):

    def test_user_repository(self):
        users=UserRepository()
        user=users.get_user("jaume.moral")
        self.assertEquals("jaume.moral@upc.edu",user['mail'])

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

Guardaremos este código en un fichero “test.py” y la ejecutamos

$ python test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK

Este test parece una tontería, pero ahora imaginemos que empezamos a hacer una implementación que se conecte al servicio de directorio real. Querremos asegurarnos que cuando pedimos el mail nos sigue devolviendo lo que toca. El test nos sirve para asegurarnos de que no rompamos cosas que ya funcionaban.

Pruebas sustituyendo funcionalidades con "mock"

Pasamos ahora a un test más complicado. Queremos testear nuestro notificador que, como sabemos, depende directamente de UserRepository y de Mailer.

Para hacer estas pruebas, tendremos que sustituir los objetos reales por unos de mentira. Queremos que tengan los mismos métodos con los mismos parámetros, pero que no hagan nada. Bueno, realmente lo que queremos es que hagan 2 cosas:

  •      que nos devuelvan los valores que a nosotros nos van bien cuando ejecutamos un test en concreto
  •      que podamos consultar si se ha hecho una cierta llamada a un método

Para hacer esto en Python, tenemos el módulo "mock", que es parte de la librería estándar desde la version 3.0 y que se puede descargar en https://pypi.python.org/pypi/mock para las versiones anteriores. El repositorio GitHub con los ejemplos hay también el paquete.

Test con una lista vacía de usernames

Vamos paso a paso: imaginemos primero que queremos enviar una notificación a una lista vacía de usernames. Nuestro UserRepository de mentira no tendrá que hacer nada y nuestro Mailer tampoco deberá enviar ningún email. Así pues, nuestro test podrá ser algo como esto:

    def test_0_user(self):
        users=mock.create_autospec(UserRepository)
        mailer=mock.create_autospec(Mailer)
        notifier=Notifier(users,mailer)
        notifier.notify(self.text,[])
        self.assertEquals(mailer.send_mail.call_count,0)

La función "create_autospec" lo que hace es crear un objeto con una interfaz igual que la clase que le pasamos, pero sin comportamiento. Esto es lo que llamamos "mock"

Este mock guarda información sobre las llamadas que se hacen y por ello podemos consultar cuántas veces se ha llamado al método send_mail con la sintaxis mailer.send_mail.call_count

Test con 1 username

Imaginemos ahora que envíen el mail a una lista de un usuario, concretamente el usuario "jaume.moral"

En este caso, nuestro UserRegistry nos deberá devolver un objeto usuario con el mail de este usuario (jaumem@fib.upc.edu) y tendremos que comprobar que hemos llamado a la función send_mail con este mail y el texto que hemos pasado el notificador.

Simular este comportamiento es tan simple como asignarle un valor al atributo "return_value" del método del objeto "mockejat"

    def test_1_user(self):
        users=mock.create_autospec(UserRepository)
        users.get_user.return_value={"mail":"jaumem@fib.upc.edu"}
        mailer=mock.create_autospec(Mailer)
        notifier=Notifier(users,mailer)
        notifier.notify(self.text,["jaume.moral"])
        mailer.send_mail.assert_called_with("jaumem@fib.upc.edu",self.text,self.text)

Aquí la aserción es un poco más compleja: estamos comprobando no sólo que hemos llamado el método send_mail, sino que la hemos llamado con unos parámetros en concreto.

Como podemos ver, la idea de estos tests es siempre preparar el objeto falso porque nos devuelva unos datos conocidos y/o comprobar que hemos llamado ciertos métodos del objeto falso.

Test con 2 usernames

Muy bien, ahora que ya sabemos que nuestros objetos mockeados nos devuelven el valor que le hemos dicho ... como hacemos que si lo llamamos más de una vez nos devuelvan objetos diferentes? Pues creando un "side effect". En vez de asignar el "return_value", que es estático, asignaremos un valor a la propiedad "side_effect" del método. Podemos asignarle una función o, en nuestro caso y para simplificar, una lista los elementos de la cual irá devolviendo uno por uno cada vez que llamamos el método.

La idea de cambiar el código que ejecuta un método de un objeto en tiempo de ejecución es lo que se conoce como "monkeypatching". Es una técnica peligrosa pero que utilizada para testear se convierte en extremadamente útil, especialmente cuando está controlada por una librería específica como es este caso.

    def test_2_users(self):
        users=mock.create_autospec(UserRepository)
        users.get_user.side_effect=[{"mail":"jaumem@fib.upc.edu"},{"mail":"anna@fib.upc.edu"}]
        mailer=mock.create_autospec(Mailer)
        notifier=Notifier(users,mailer)
        notifier.notify(self.text,["jaume.moral","anna.casas"])
        mylist=[
            call("jaumem@fib.upc.edu",self.text,self.text),
            call("anna@fib.upc.edu",self.text,self.text)
        ]
        self.assertEqual(mailer.send_mail.call_args_list,mylist)

En este caso la aserción comprueba si hemos hecho una serie de llamadas con sus respectivos parámetros. Una aserción más simple sería ver que hemos llamado al send_mail 2 veces, pero la opción elegida es mucho más expresiva.

Conclusiones

Testear en Python es fácil y tenemos formas mucho más simples y potentes que las que nos ofrecen lenguajes más estrictos como Java.

En este artículo sólo hemos rascado un poco la superficie de lo que nos ofrecen las librerías Mock y Unittest. Hay otras formas de utilizarlas como aplicando el decoratorpatch, la clase MagicMock ... pero todo esto lo dejamos para el lector que tenga curiosidad.

Puede encontrar todo el código de los ejemplos a https://github.com/jaumemoral/python-testing/

Síguenos en

Els nostres articles del bloc d'inLab FIB

         
         

inLab FIB incorpora esCert

Icona ESCERT

inLab es miembro de