Testing i Mocking en Python

testunit-phyton-inlabfib

Inici » Actualitat »

Testing i Mocking en Python

Test unitaris, d’integració, de regressió, d’acceptació, de comportament, TDD… Hi ha molts tipus de test i tècniques de testing i moltes vegades la frontera entre elles no és tan clara com voldríem.

En aquest article no intentarem explicar cap d’aquestes tècniques o filosofies. Anirem a un cas concret i clar: volem provar una funcionalitat del nostre programa que depèn d’una altra sobre la qual no tenim control. I volem poder fer-ho d’una forma automatitzada i reproduïble utilitzant les eines que ens proporciona el llenguatge Python.

Molt millor amb un exemple

El nostre exemple és molt simple: tenim una classe que ens permet enviar notificacions per mail a una llista d’usuaris identificats pel seu username. Aquesta dependrà d’una classe que retorna informació sobre els usuaris (en principi a partir d’una base de dades, tot i que al nostre exemple ho hem simplificat) i una altra classe que permet enviar mails.

users.py

Aquesta és la classe que hauria de consultar la base de dades per obtenir la informació, però com que no és l’objectiu de l’article, farem que tingui aquesta funcionalitat 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 volem podem executar el programa passant el missatge i la llista d’usernames com a paràmetres

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

Donat aquest sistema, com podem fer proves de que realment s’envien els mails correctament… sense que realment s’enviïn? Com podem simular que el UserRepository ens retorni un cert valor per poder provar el nostre codi? I sobretot i no menys important… com poder fer això d’una forma fàcil?

Abans de continuar, hem de dir que ja d’entrada hem pres una decisió de disseny que ens ajudarà a escriure testos pel nostre codi: hem creat la classe Notifier de forma que li puguem “injectar” les dependències, és a dir, passar-li per paràmetre en el moment de la creació els objectes amb els quals s’haurà de comunicar, en comptes de crear-los ella mateixa. Això ens facilita la substitució d’aquestes dependències durant els testos.

Testos unitaris amb “unittest”

Unittest és un paquet estàndard de Python que permet, com el seu nom indica, fer testos unitaris. La forma de fer-ho servir és semblant al JUnit de Java, i bàsicament consisteix en

  • Fer una classe que estengui unittest.TestCase

  • Afegir-hi mètodes que comencin per “test_” amb la implementació dels testos

  • Fer assercions per comprovar que el resultat és l’esperat

Per començar, farem una petita prova per testejar la implementació “de mentida” que hem fet del nostre servei d’usuaris. Sabem que si anem a buscar l’usuari “jaume.moral” el mail que ens hauria de retornar es “jaume.moral@upc.edu”. Escriurem un test per codificar aquest comportament.

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()

Guardarem aquest codi en un fitxer “test.py” i l’executarem

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

Aquest test sembla una tonteria, però ara imaginem que comencem a fer una implementació que es connecti al servei de directori real. Voldrem assegurar-nos que quan demanem el mail ens segueix retornant el que toca. El test ens serveix per assegurar-nos que no trenquem coses que ja funcionaven.

Testos substituint funcionalitats amb “mock”

Passem ara a un test més complicat. Volem testejar el nostre notificador que, com sabem, depèn directament de UserRepository i de Mailer.

Per fer aquestes proves, haurem de substituir els objectes reals per uns de mentida. Volem que tinguin els mateixos mètodes amb els mateixos paràmetres, però que no facin res. Bé, realment el que volem és que facin 2 coses:

  • que ens retornin els valors que a nosaltres ens van bé quan executem un test en concret

  • que puguem consultar si s’ha fet una certa crida a un mètode

Per fer aixo en Python, tenim el modul “mock”, que és part de la llibreria estàndard des de la versio 3.0 i que es pot descarregar a https://pypi.python.org/pypi/mock  per les versions anteriors. Al repositori GitHub amb els exemples hi ha també el paquet.

Test amb una llista buida d’usernames

Anem pas a pas: imaginem primer que volem enviar una notificació a una llista buida de usernames. El nostre UserRepository de mentida no haurà de fer res i el nostre Mailer tampoc haurà d’enviar cap mail. Així doncs, el nostre test podrà ser quelcom com això:

    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ó “create_autospec” el que fa és crear un objecte amb una interfície igual que la classe que li passem, però sense comportament. Això és el que anomenem “mock”

Aquest mock guarda informació sobre les crides que s’hi fan i per això podem consultar quantes vegades s’ha cridat al mètode send_mail amb la sintaxi mailer.send_mail.call_count

Test amb 1 username

Imaginem ara que enviem el mail a una llista d’un usuari, concretament l’usuari “jaume.moral”

En aquest cas, el nostre UserRegistry ens haurà de retornar un objecte usuari amb el mail d’aquest usuari (jaumem@fib.upc.edu) i haurem de comprovar que hem cridat a la funció send_mail amb aquest mail i el text que hem passat al notificador.

Simular aquest comportament és tan simple com assignar-li un valor a l’atribut “return_value” del mètode de l’objecte “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í l’asserció és un pèl més complexa: estem comprovant no només que hem cridat el mètode send_mail, sinó que l’hem cridat amb uns paràmetres en concret.

Com podem veure, la idea d’aquests testos és sempre preparar l’objecte fals perquè ens retorni unes dades conegudes i/o comprovar que hem cridat certs mètodes de l’objecte fals.

Test amb 2 usernames

Molt bé, ara que ja sabem que els nostres objectes mockejats ens retornen el valor que li hem dit… com fem que si el cridem més d’una vegada ens retornin objectes diferents? Doncs creant un “side effect”. En comptes d’assignar el “return_value”, que és estàtic, assignarem un valor a la propietat “side_effect” del mètode. Podem assignar-li una funció o, en el nostre cas i per simplificar, una llista els elements de la qual anirà retornant un per un cada vegada que cridem el mètode.

La idea de canviar el codi que executa un mètode d’un objecte en temps d’execució és el que es coneix com a “monkeypatching”. És una tècnica perillosa però que utilitzada per testejar es converteix en extremadament útil, especialment quan està controlada per una llibreria específica com és aquest cas.

    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 aquest cas l’asserció comprova si hem fet una sèrie de crides amb els seus respectius paràmetres. Una asserció més simple seria veure que hem cridat al send_mail 2 vegades, però l’opció triada és molt més expressiva.

Conclusions

Testejar en Python és fàcil i tenim formes molt més simples i potents que les que ens ofereixen llenguatges més estrictes com Java.

En aquest article només hem rascat una mica la superfície del que ens ofereixen les llibreries Mock i Unittest. Hi altres formes d’utilitzar-les com ara aplicant el decorator @patch, la classe MagicMock… però tot això ho deixem pel lector que tingui curiositat.

Podeu trobar tot el codi dels exemples a https://github.com/jaumemoral/python-testing/