Amb els últims projectes en Python (el janitor i el pyxavi) he probat de posar en pràctica a casa les bones maneres que tenim a la feina. De tots ells, els tests unitaris són els que trobo més interessants.

Anem a descobrir pas a pas l'art dels Tests Unitaris a Python!

A grans trets

El que explicaré aquí està basat en el meu dia a dia. Com tot, hi ha coses que igual algú les faria millor de manera diferent. Benvingut sigui! Per la meva banda, intentaré estructurar-ho de la següent manera:

  1. Preparant l'entorn amb Poetry
  2. Primers passos, testejant codi estàtic
  3. Cobrint més casos: parametrització
  4. Testejant classes de mètodes estàtics
  5. Testejant classes instanciades
  6. Dependency injection, mocks i patches
  7. Fixtures
  8. Lliguem-ho tot plegat
  9. Cloenda

1. Preparant l'entorn amb Poetry

Tothom a Python coneix el pip, una forma de gestionar paquets que té ja uns anys però que és senzilla i eficaç. Després hi ha també el pyenv o virtualenv, uns gestors d'entorns virtuals per tal d'assegurar que el codi que correm no interfereixi amb altres, aillant l'entorn. Poetry gestiona aquests dos a la vegada, i més.

Poetry és un gestor de paquets i dependències, que fa la vida ben fàcil al desenvolupador Python. No m'hi mataré molt a defendre Poetry, ja ho vaig fer a Les èines del programador Python - part 1. Que sàpigues que només es tracta d'un gestor, que tot es pot fer sense Poetry, i que la decisió depèn de tu. Al final, el que necessites és un codi, una estructura de directoris, i tenir les dependències instal.lades.

Sense més història, anem a crear un nou projecte. Obrim un terminal, ens movem al directori on tenim els nostres projectes i creem el projecte amb la següent comanda:

poetry new myapp

i ell tot solet ens crea un directory myapp amb la següent estructura:

myapp
├── README.md
├── pyproject.toml
├── myapp
│   ├── __init__.py
└── tests
    ├── __init__.py

Podem veure que al crear l'arxiu pyproject.toml ha agafat la versió Python per defecte que tenim al nostre sistema (en el meu cas 3.10) i l'autor definit globalment com a usuari de git ("Xavi" en el meu cas). El reste són valors per defecte.

Com que aquest article va de tests, anem a deixar-ho tot llest. Afegim una secció per a les dependències que només cal instal.lar mentre estem desenvolupant. Això vol dir que en un entorn de producció no s'instal.laran. Quines dependències? Doncs pytest, que és la llibreria que ens porta la funcionalitat d'executar tests unitaris.

[tool.poetry.group.dev.dependencies]
pytest = "^7.0"

L'arxiu pyproject.toml quedaria així:

[tool.poetry]
name = "myapp"
version = "0.1.0"
description = ""
authors = ["Xavi <***@***.net>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.10"

[tool.poetry.group.dev.dependencies]
pytest = "^7.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Ara només queda dir-li a Poetry que instal.li les dependències:

$ poetry install

Creating virtualenv myapp-rzeoV-K4-py3.10 in /Users/xavi/Library/Caches/pypoetry/virtualenvs
Updating dependencies
Resolving dependencies... (1.1s)

Writing lock file

Package operations: 7 installs, 0 updates, 0 removals

  • Installing attrs (22.2.0)
  • Installing exceptiongroup (1.1.1)
  • Installing iniconfig (2.0.0)
  • Installing packaging (23.0)
  • Installing pluggy (1.0.0)
  • Installing tomli (2.0.1)
  • Installing pytest (7.2.2)

Installing the current project: myapp (0.1.0)

I així de fàcil tenim una aplicació llesta per començar a desenvolupar, amb suport per tests.

2. Primers passos, testejant codi estàtic

Comencem amb un codi fàcil: una funció que donat un paràmetre retorna si és un nombre primer o no. Per a tal efecte crearem una llibreria que contindrà la funció: crea un arxiu anomenat mylibrary.py dins del directory myapp i afegeix el següent codi:

def is_prime(n: int) -> bool:
    for i in range(2, n):
        if n % i == 0:
            return False
    return True

Executant el codi

Si volguéssim provar aquest codi podriem obrir un intèrpret Python. Obre un terminal, mou-te al directori principal de l'app i executa python per obrir un intèrpret (introdueix exit() per sortir). Quan apareguin els caracters >>> ja podem introduir les nostres instruccions. Caldra importar la llibreria com a mòdul per tal de fer-la servir, tal com es veu en la següent captura: python_interpreter

Afegint un test

Molt bé, ara el que volem és testejar la funció, és a dir, assegurar-nos que donat un paràmetre conegut obtindrem de la funció un resultat esperat. Llavors, escriurem un parell de tests, un per un cas on el paràmetre ha de donar True i un altre per un cas on ha de donar False. Per això crearem un arxiu test_mylibrary.py dins del directory tests amb el següent codi:

from myapp.mylibrary import is_prime

def test_is_prime_is_true():
    result = is_prime(2)

    assert result is True

def test_is_prime_is_false():
    result = is_prime(6)

    assert result is False

Què tenim aquí?

  1. Per convenció, els arxius de test van dins del directori tests i comencen per test_. Això farà que l'executor de tests llegeixi tots els arxius amb tests a executar
  2. Per convenció, els tests són simplement funcions que comencen per test_. Això farà que l'executor de tests els executi.
  3. Un test només és una funció que executa la funció a testejar, recull el resultat i el compara amb el que s'espera.
  4. La comanda assert no és única dels tests, la podem trobar a qualsevol codi, i vindria a ser un if que si no es compleix la condició aixeca una Excepció. Jo la he vist utilitzar en codi en el que és imperatiu que un paràmetre sigui alguna cosa en especial, però segurament tothom ho relaciona amb els tests.

Com executem el test?

Els tests s'executen des del terminal. Ens mourem al directori principal de l'aplicació i (ja que ho tenim tot lligat amb Poetry) executarem:

poetry run pytest

i obtindrem un resultat com el següent: pytest_run

Què hi veiem?

  1. Al costat del nom de l'arxiu veiem 2 punts en verd. Cada punt és un test que ha funcionat correctament.
  2. El resultat està en verd, anunciant que 2 tests han passat correctament.

Si al final no estem fent el projecte amb Poetry, que sapigueu que Pytest és un mòdul de Python que s'instal.la al sistema i que es pot executar directament, però llavors no estem fent servir la configuració del projecte i haurem de dir-li on estan els tests:

pytest tests

Què passa si falla?

Clar, aquí hem anat a tirada segura. Canviem el valor 6 per un 5 en el test test_is_prime_false() i executem: pytest_run_1_fail

Aquí veiem que:

  1. Els 2 punts verds canvien a un punt (test correcte) i una F en vermell (Falla).
  2. Ens apareix un tros de codi focalitzat al punt de falla marcat amb un >, l'assert, i mostrant quin és el resultat obtingut comparat amb el resultat esperat
  3. Ens apareix al final un resum de les falles, amb l'arxiu de test, la funció, i l'error.
  4. Ens diu que 1 test a fallat i 1 altre a passat.

3. Cobrint més casos: parametrització

Si teniu una mica d'ull per al codi haureu notat que els dos tests que hem fet al punt anterior són bàsicament el mateix canviant el paràmetre i el resultat esperat. I si poguéssim definir aquests dos com a paràmetres del test i així reduir el codi? Anem a fer ús de la parametrització.

Primer de tot hem d'importar el mòdul pytest. Afegim la següent línia al capdamunt de l'arxiu de test:

import pytest

I seguidament afegim el següent codi com a tercer test:

@pytest.mark.parametrize(
    argnames=('value', 'expected_result'),
    argvalues=[
        (0, True),
        (1, True),
        (2, True),
        (3, True),
        (4, False),
        (5, True),
        (6, False),
        (7, True),
        (8, False),
        (9, False),
        (10, False),
    ],
)
def test_is_prime(value: int, expected_result: bool):
    result = is_prime(value)

    assert result is expected_result

Què tenim aquí?

  1. Ara la funció del test accepta 2 arguments: el valor amb el que testejarem la funció i el resultat esperat.
  2. Hem definit la parametrització, amb un primer paràmetre que defineix el nom dels arguments i un segon paràmetre amb els valors amb els que executarem el test cada cop.

Al executar-ho novament, obtindrem el següent resultat: pytest_run_2

Fixeu-vos amb el nombre de tests. De cop hem passat de 2 a 13!

Obtenir més informació de l'execució del test

La veritat és que caldria veure'n més del test, per exemple quin test s'ha executat i amb quin resultat, i amb els que usen paraetrització, vull veure la combinació de parametres. Podriem executar el test amb els següents paràmetres:

poetry run pytest -ra -q -vvv

Que donarà la següent informació: pytest_run_3

Jo el que faig és editar l'arxiu pyproject.toml i afegir una secció que defineix les opcions per als tests:

[tool.pytest.ini_options]
addopts = "-ra -q -vvv"

i així no haig de recordar els paràmetres cada cop.

Què tal es veu amb falles?

Anem a provar el mateix, canviem el resultat esperat pel valor 9 i 10 i posem que hauria de ser True. Al executar tenim el següent: pytest_run_3_fail

Com veiem hem millorat la informació rebuda al llistat de tests que es passen pero bàsicament la informació focalitzada a la falla és similar.

4. Testejant classes de mètodes estàtics

El cas més senzill de classes són les biblioteques de mètodes estàtics, que no cal instanciar la classe i simplement els seus mètodes es criden com si fossin funcions estàtiques. Per exemple, podriem evolucionar la classe mylibrary.py a my_static_class.py amb un codi com el següent:

class MyStaticClass:

    def is_prime(n: int) -> bool:
        for i in range(2, n):
            if n % i == 0:
                return False
        return True

i llavors, tindriem el següent test associat test_my_static_class.py amb el codi següent:

from myapp.my_static_class import MyStaticClass
import pytest

@pytest.mark.parametrize(
    argnames=('value', 'expected_result'),
    argvalues=[
        (0, True),
        (1, True),
        (2, True),
        (3, True),
        (4, False),
        (5, True),
        (6, False),
        (7, True),
        (8, False),
        (9, False),
        (10, False),
    ],
)
def test_is_prime(value: int, expected_result: bool):
    result = MyStaticClass.is_prime(value)

    assert result is expected_result

Què veiem aquí?

  1. Ara importem la classe i no la funció. Podriem importar la funció específicament, però llavors hauriem de definir tots i cadascun dels mètodes a testejar, poc pràctic.
  2. Al test, cridem al mètode via la classe amb MyStaticClass.is_prime(), tal com ho fariem amb el codi normal cridant un mètode static d'una classe.
  3. El reste és exactament el mateix.

Al executar, tenim (he afegit el test als que ja teníem, sense treure els anteriors): pytest_run_4

5. Testejant classes instanciades

No entraré a explicar la programació orientada a objectes ara, però val a dir que tenint en memòria un objecte estem donant per suposat que hi ha tot un seguit d'accions que s'executaran de forma transparent a nosaltres, i que hem de tenir en compte a l'hora de dissenyar el test. Però anem a veure-ho amb exemples i pas a pas. Aquesta secció serà una mica llarga, aneu a agafar un refresc.

Donada una classe petitona...

Anem a definir una classe que ens servirà per entendre les primeres passes. Creem un arxiu config.py dins del directori de codi myapp i l'omplirem amb el següent codi:

class Config:

    PARAMS = {
        "filename": "docs/main.md"
    }

    def get_param(self, name: str) -> any:
        if name not in self.PARAMS:
            raise RuntimeError(f"could not find param [{name}]")

        return self.PARAMS[name]

Què fa aquesta classe?

  1. Serà un objecte per a establir la configuració de l'aplicació.
  2. Té una propietat que conté els paràmetres de l'aplicació
  3. Té un mètode que retorna la propietat que se li demana o aixeca un error que pararà l'aplicació.

Nota: Aquí estic simplificant (i molt) una classe per a configuració d'aplicacions basat en arxius YAML que faig servir habitualment mitjançant el meu mòdul pyxavi. Si teniu interès podeu veure la classe aquí i el seu corresponent test aquí.

Ara anem a fer-li un arxiu de test relacionat. Què volem testejar?

  • Que quan li demani el paràmetre filename em retorni un valor de tipus texte i no aixequi cap error
  • Que quan li demani un paràmetre que no existeix aixequi un error

Llavors, creem un arxiu test_config.py dins del directori tests amb el següent contingut:

from myapp.config import Config
from unittest import TestCase

def test_config_has_string_filename_param():
    config = Config()
    filename = config.get_param("filename")

    assert type(filename) == str
    assert type(filename) != int

def test_raise_if_param_does_not_exist():
    config = Config()

    with TestCase.assertRaises(config, RuntimeError):
        config_value = config.get_param("unexisting_param")

Què hi tenim aquí?

  1. El primer test case és molt similar als anteriors: instanciem l'objecte i executem el mètode a testejar, passant-li l'argument conegut i comprovant que rebem el resultat esperat. No és una bona pràctica testejar el valor d'un paràmetre de configuració (el valor de filename), així que com que el que realment volem és veure que el paràmetre existeix i que no salta cap error, podem mirar que és de tipus string. El segon assert està de més, però vull demostrar que el type() funciona. Servirà.
  2. El segon test case és un xic diferent, per que supeditem l'execució a un contexte que validarà que al executar-se s'aixecarà un error de tipus RuntimeError des de l'objecte config. Fixeu-vos que per a tal cosa hem hagut d'importar el mòdul TestCase.
  3. Al cap i a la fi, en aquest exemple no veiem gran diferència amb els exemples anteriors: Es tracta d'instanciar l'objecte i executar-ne el mètode, passant-li un argument i comprovant que fa el que hauria.

6. Dependency injection, mocks i patches

El següent pas en complexitat és fer un test a una classe que aplica injecció de dependències. Per què? Primer per que és una pràctica comú per aïllar les responsabilitats d'una classe i segon per que ens proporciona un exemple perfecte per introduir els conceptes de mocks i patches.

Costa de trobar referències en català sobre injecció de dependències. He trobat aquesta explicació amb la que estic prou content, encara que sigui sobre PHP (la teoria aplica a la programació orientada a objectes, independentment del llenguatge). A més, sembla ser que hi ha discussions en com implementar-ho correctament a Python, llegint aquesta entrada a StackOverflow, tot i que trobo a faltar referències a Protocol.

Al final dóna una mica igual. El que necessitem saber és que en algunes situacions (moltes més de les que voldriem), fer tests se'ns complica i molt degut a dependències amb altres objectes que realment no és responsabilitat nostre (o d'aquest test en particular) de cobrir. I per tant, hem de tirar d'unes èines que ens facilitin la feina d'aïllar el test. Per això se li diuen tests unitaris, perque testejem unitàriament el codi en qüestió, i no tot l'arbre de dependències que el nostre codi implica. I aquestes èines són el mock i el patch.

Quina diferència hi ha entre un mock i un patch?

Un Mock és un objecte específicament preparat per simular qualsevol altre objecte. Idealment l'utilitzarem en lloc de l'objecte a injectar en la dependency injection i ens permetrà avaluar les crides que rep.

Un Patch és l'habilitat de sobreescriure una funcionalitat concreta en una classe a la que no tenim accés directe, per tal de canviar intencionalment el funionament i evitar així que un test executi parts del codi no volgudes. Un exemple concret seria evitar que un mètode que esborra entrades a la base de dades s'executi quan estem corrent tests, ja que no voldrem realment modificar dades reals quan estem només comprovant codi.

Donada una classe que rep un objecte injectat

Anem a veure alguns exemples. Per això farem una altra classe que rebi injectat el nostre anterior Config i l'utilitzi internament. Creem un altre arxiu my_class.py dins del directory myapp amb el següent codi:

from myapp.config import Config
import os

class MyClass:

    def __init__(self, config: Config) -> None:
        self.config = config

    def get_content(self) -> str:
        filename = self.config.get_param("filename")

        if os.path.exists(filename):
            with open(filename, 'r') as stream:
                return stream.read()
        else:
            raise RuntimeError("File not found")

Què veiem aquí?

  1. La classe s'inicialitza injectant un objecte Config, que el guarda en una propietat interna.
  2. La classe té un mètode que utilitza l'objecte injectat i fa alguna operació fent servir altres mòduls.

Nota: Les operacions aquí són exemples que demostren l'ús de mòduls externs a la classe, amb la finalitat de crear situacions per treballar amb els tests després.

Què volem testejar aquí?

  1. Que l'objecte Config que s'usa internament és el mateix que l'objecte que li injectem.
  2. Que si l'arxiu al que es fa referència no existeix el mètode aixeca un error
  3. Que si l'arxiu al que es fa referència existeix el mètode retorna el seu contingut.

Què no volem testejar aquí?

  1. El funcionament de l'objecte Config: aquest ja ha de tenir el seu propi test unitari.
  2. El funcionament del mòdul os: aquest és un mòdul extern i per tant confiem en el seu funcionament i els seus propis tests pels seu desenvolupador.

Així doncs, creem un arxiu test_my_class.py dins del directory tests amb el següent contingut:

from myapp.my_class import MyClass
from myapp.config import Config
import os
from unittest.mock import patch, Mock, mock_open
import pytest
from unittest import TestCase

def test_init():
    config = Mock()
    instance = MyClass(config)

    assert isinstance(instance, MyClass)
    assert instance.config == config

def test_get_content_raise_when_no_file():
    # Prepare the Config object to inject
    filename = "filename.md"
    config = Mock()
    config.__class__ = Config
    mocked_get_param = Mock()
    mocked_get_param.return_value = filename
    config.get_param = mocked_get_param

    # Prepare the output of the os module
    mocked_os_path_exist = Mock()
    mocked_os_path_exist.return_value = False

    # Instantiate
    instance = MyClass(config)

    # Patch what should not be executed
    with patch.object(os.path, "exists", new=mocked_os_path_exist):

        # This test execution expects to raise an error
        with TestCase.assertRaises(instance, RuntimeError):
            content = instance.get_content()

    # Asserting now that we used the expected functions
    mocked_get_param.assert_called_once_with("filename")
    mocked_os_path_exist.assert_called_once_with(filename)

@patch("builtins.open", mock_open(read_data="I am content"))
def test_get_content_success():
    # Prepare the Config object to inject
    filename = "filename.md"
    config = Mock()
    config.__class__ = Config
    mocked_get_param = Mock()
    mocked_get_param.return_value = filename
    config.get_param = mocked_get_param

    # Prepare the output of the os module
    mocked_os_path_exist = Mock()
    mocked_os_path_exist.return_value = True

    # Instantiate
    instance = MyClass(config)

    # Patch what should not be executed
    with patch.object(os.path, "exists", new=mocked_os_path_exist):
        content = instance.get_content()

    # Main assert
    assert content == "I am content"

    # Asserting now that we used the expected functions
    mocked_get_param.assert_called_once_with("filename")
    mocked_os_path_exist.assert_called_once_with(filename)

I doncs, aquest arxiu de test:

  1. Conté tres test cases: un que comprova que l'objecte injectat és el que està disponible per l'ús intern, un que comprova que per a un arxiu que no existeix el mètode aixecarà un error i un altre que comprova el retorn del contingut de l'arxiu donat
  2. En cap moment executem codi que no és estrictament de la classe MyClass. Per això utilitzem tot un conjunt de Mocks i algun Patch per evitar que el fluxe d'execució se'n vagi cap als mòduls externs
  3. Estem repetint alguns snippets de codi que més endavant abstraurem, tot al seu temps.

Anem a pams, veiem cadascun dels tests poc a poc a veure què fan.

El test_init

Aquest test és molt simple, només volem fer comprovacions inicials, però ja introdueix el Mock com a objecte a injectar:

config = Mock()

Podeu trobar més informació a la seva documentació oficial. És un objecte que ens permet "emular" qualsevol altre objecte. La idea és que injectem aquest en comptes del que la classe espera, i d'aquesta manera podem comprovar que es fa el que volem.

Total, que el següent és instanciar la classe de forma natural passant-li l'objecte que espera

instance = MyClass(config)

Primer de tot comprobem que la classe instanciada ho és de la classe que esperem. Sembla una tirada segura, però també podriem usar-ho en cas que estem utilitzant herència i volem comprovar que la classe és també una instància de la classe pare.

assert isinstance(instance, MyClass)

Finalment, només volem comprovar que el que hem injectat és de fet l'objecte que queda llest per usar. Sembla una mica estúpid, però no seria el primer cop que introduim un canvi tonto i després resulta que l'objecte ja no està disponible:

assert instance.config == config

Aquest test és només per assegurar-nos que la classe instanciada i l'objecte injectat són el que esperem. No hauria de donar problemes, i ens servirà d'assegurança sobre possibles canvis futurs.

El test_get_content_raise_when_no_file

Aquest test ja és més interessant. Aquí volem comprovar que el mètode reacciona correctament si se li demana un arxiu que no existeix.

Primer de tot, preparem l'objecte config degudament, ja que es farà servir dins el mètode de la classe:

filename = "filename.md"
config = Mock()
config.__class__ = Config
mocked_get_param = Mock()
mocked_get_param.return_value = filename
config.get_param = mocked_get_param

Aquí veiem un parell de casos d'ús de l'objecte Mock: Primer com a objecte en sí al definir una instància config. Fixeu-vos que fins i tot podem definir la classe que ha d'emular en cas que fos necessari (per exemple, si el mètode fa servir un isinstance(), que no és el cas aquí). Després estem fent servir el Mock un altre cop per emular un mètode, definint primer quin valor haurà de retornar (vegeu el return_value) i després assignant-lo com a mètode de la classe config. Ara tenim una emulació d'un objecte config que retornarà sempre filename.md quan se li demani qualsevol crida a get_param().

A continuació preparem l'emulació a la crida al mòdul os, per tal que retorni False per fer veure que l'arxiu no existeix quan, de fet, no executa res:

mocked_os_path_exist = Mock()
mocked_os_path_exist.return_value = False

A continuació simplement instanciem la classe, per tal de fer-la servir més endavant:

instance = MyClass(config)

I ara ve la màgia: definim un contexte en el qual estem fent "parchejant" (fem un patch) el mòdul os.path en el seu mètode exists, per tal que en comptes d'executar el codi del mòdul en si, faci servir l'objecte mocked_os_path_exist que hem preparat:

with patch.object(os.path, "exists", new=mocked_os_path_exist):

Dins d'aquest contexte, totes les crides a os.path.exists() retornaran False, tal com hem definit al mocked_os_path_exist.return_value.

Ara, com que veiem que el codi ha d'aixecar un error quan l'arxiu no existeix, usarem l'estratègia que hem fet servir al test_config per tal de capturar l'error esperat:

with TestCase.assertRaises(instance, RuntimeError):

I finalment, executem el mètode de la instància com si fos una crida normal i corrent.

content = instance.get_content()

Fixeu-vos que l'estem executant dins de 2 contextes: el primer és el que modifica el funcionament normal del os.path.exists() i el segon és el que captura l'error. Ho poso aquí tot junt:

# Patch what should not be executed
with patch.object(os.path, "exists", new=mocked_os_path_exist):

    # This test execution expects to raise an error
    with TestCase.assertRaises(instance, RuntimeError):
        content = instance.get_content()

Ara que hem executat el mètode, el que volem és assegurar-nos que els objectes Mock que hem preparat s'han fet servir de la forma esperada, i aquí és justament on ens aprofitem d'haver fet servir un Mock i no simplement una classe buida preparada:

mocked_get_param.assert_called_once_with("filename")
mocked_os_path_exist.assert_called_once_with(filename)

El primer comprova que l'objecte mock_get_param ha estat executat exactament un cop passant-li el paràmetre "filename" i no cap altre. El mateix amb mocked_os_path_exist, on comprovem que s'està verificant que existeix l'arxiu definit a la variable filename.

Bé, sembla complicat però no ho és tant no?

El test_get_content_success

Un cop hem vist el test anterior, el natural és fer-ne ara un que comprovi quan l'arxiu si que existeix. Així, gran part del codi del test és el mateix que l'anterior:

Preparar l'objecte config:

filename = "filename.md"
config = Mock()
config.__class__ = Config
mocked_get_param = Mock()
mocked_get_param.return_value = filename
config.get_param = mocked_get_param

Preparar el mock pel os.path.exists(), dient-li aquest cop que ha de retornar True:

mocked_os_path_exist = Mock()
mocked_os_path_exist.return_value = True

Instanciar la classe:

instance = MyClass(config)

Definir el contexte per tal de sobre-escriure el funcionament del os.path.exists() amb el que hem preparat, juntament amb l'execució del mètode:

with patch.object(os.path, "exists", new=mocked_os_path_exist):
    content = instance.get_content()

I ara parem un moment. Si mirem el codi, veurem que estem llegint l'arxiu si aquest existeix (tal com estem testejant ara). Això vol dir que si no fem res més, Python intentarà obrir l'arxiu filename.md que tenim definit en el test, i fallarà per que de fet l'arxiu no existeix. I sabem que com que és un test unitari, no volem que el sistema vagi a buscar res al sistema d'arxius. Hem de "parchejar" també la lectura de l'arxiu. Per a fer tal cosa, tenim l'habilitat de definir accions Patch a nivell de decorador (decorator en anglès. Aquí teniu una explicació ràpida i senzilla de decorators en Python, en anglès). Bàsicament fem el mateix Patch que més amunt, però s'aplica a tota la funció de test i queda més elegant. Fixeu-vos en la línia de codi just abans de definir la funció de test:

@patch("builtins.open", mock_open(read_data="I am content"))
def test_get_content_success():
   ...

Aquest Patch defineix que totes les crides al open, que venen englobades per la llibreria interna builtins, retornaran el contingut que li definim a read_data. De fet, quan tenim molts Patch a fer, i sobretot quan hem de repetir-los a vàris test cases, surt més a compte aquesta opció ja que el codi queda més net i les definicions de l'objecte Mock passen a ser funcions. Vegeu com ho faig per exemple en el test_storage.py de la meva llibreria pyxavi.

Continuem. Després de cridat el mètode, el que volem és comprovar que el resultat és exactament el que esperem, així que usem un assert clàssic contra el que hem definit que el builtins.open retorni:

assert content == "I am content"

I per acabar, el parell de comprovacions sobre els objectes mockejats, tal com hem fet en l'anterior test:

mocked_get_param.assert_called_once_with("filename")
mocked_os_path_exist.assert_called_once_with(filename)

Al executar el test veurem que els tres passen sense problemes:

poetry run pytest tests/test_my_class.py

Fixeu-vos en la comanda anterior: aquest cop he definit quin arxiu de test vull executar especificament, ja que estic concentrat en aquest i ara executar-los tots podria consumir-me més temps.

7. Fixtures

Un cop més, si som una mica espavilats veurem que dels tres tests anteriors, dos són són molt semblants. Pensant en com abstraure algun codi fora dels test cases, veurem que sempre fem servir el mateix objecte mockejat config. És el que s'anomena Fixture, i per simplificar-ho, és una funció que retorna un valor que serà utilitzat com a argument en un test case. És reutilitzable i al seu temps accepta també més fixtures. Anem a veure un exemple:

Anem a evolucionar l'arxiu test_my_class.py amb el que hem treballat a la secció anterior. Primer, creem un parell de funcions simples al principi de l'arxiu abstraient codi dels dos _testcases anteriors:

@pytest.fixture
def filename():
    return "filename.md"

@pytest.fixture
def mocked_config(filename):
    # Prepare the Config object to inject
    config = Mock()
    config.__class__ = Config
    mocked_get_param = Mock()
    mocked_get_param.return_value = filename
    config.get_param = mocked_get_param

    return config

Què veiem aquí?

  1. Les fues funcions venen definides amb un decorador @pytest.fixture. Això farà que el que retornin pugui ser usat com a paràmetre en qualsevol altre funció automàticament.
  2. La funció filename no retorna res més que un texte amb un nom d'arxiu.
  3. La funció mocked_object espera un paràmetre filename, que serà la funció filename() que hem definit anteriorment
  4. La funció mocked_object crea i retorna l'objecte Mock config i prepara el mètode get_param usant també un Mock, tal com ho feien els dos test cases a la secció anterior.

I ara? Doncs cal modificar els dos mètodes que hem fet a la secció anterior per tal que utilitzin els fixtures que acabem de definir:

def test_get_content_raise_when_no_file(mocked_config, filename):
    # Prepare the output of the os module
    mocked_os_path_exist = Mock()
    mocked_os_path_exist.return_value = False

    # Instantiate
    instance = MyClass(mocked_config)

    # Patch what should not be executed
    with patch.object(os.path, "exists", new=mocked_os_path_exist):

        # This test execution expects to raise an error
        with TestCase.assertRaises(instance, RuntimeError):
            content = instance.get_content()

    # Asserting now that we used the expected functions
    mocked_config.get_param.assert_called_once_with("filename")
    mocked_os_path_exist.assert_called_once_with(filename)

@patch("builtins.open", mock_open(read_data="I am content"))
def test_get_content_success(mocked_config, filename):
    # Prepare the output of the os module
    mocked_os_path_exist = Mock()
    mocked_os_path_exist.return_value = True

    # Instantiate
    instance = MyClass(mocked_config)

    # Patch what should not be executed
    with patch.object(os.path, "exists", new=mocked_os_path_exist):
        content = instance.get_content()

    # Main assert
    assert content == "I am content"

    # Asserting now that we used the expected functions
    mocked_config.get_param.assert_called_once_with("filename")
    mocked_os_path_exist.assert_called_once_with(filename)

Quins canvis veiem?

  1. La definició de l'objecte config ja no està dins dels test cases.
  2. L'objecte config ara es diu mocked_config i ve passat per paràmetre en la definició de la funció dels test cases
  3. L'assert que comprova que hem cridat un cop la funció get_param amb el paràmetre "filename" ha canviat per definir que accedim al mock assignat dins del mocked_config.
  4. Rebem també per paràmetre el filename per tal de fer l'últim assert, veient així que un fixture es pot usar tant en el codi com en un altre fixture

Elegant, no? Ara tenim uns fixtures que es reutilitzen, simplificant el codi i el seu manteniment. I l'execució també és ben mona: test_my_class

8. Lliguem-ho tot plegat: Parametrització, Fixtures, Mock i Patch

I aquí ja ho lliguem tot, només per donar-nos el plaer de veure-ho tot funcionant conjuntament. Val a dir que aquest és un exercici innecessari moltes vegades, ja que no és desitjable que un test contingui masses fluxes condicionals. És preferible tenir diversos test cases que analitzen branques específiques del fluxe d'execució que no pas un sol test case on comprovem totes i cadascuna de les branques, per que el codi és quelcom viu, hi haurà canvis, Pull Requests, noves funcionalitats, i els tests han de viure amb el codi, i això vol dir que si els tests són massa complexos, costarà més de mantenir-los, i es pot tornar una feina farragosa.

Tanmateix, obrim un altre cop l'arxiu test_my_class.py amb el que hem treballat a les seccions anteriors i afegirem el següent test case nou:

@pytest.mark.parametrize(
    argnames=('path_exists', 'expected_result'),
    argvalues=[
        (False, False),
        (True, "I am content"),
    ],
)
@patch("builtins.open", mock_open(read_data="I am content"))
def test_get_content(mocked_config, filename, path_exists, expected_result):
    # Prepare the output of the os module
    mocked_os_path_exist = Mock()
    mocked_os_path_exist.return_value = path_exists

    # Instantiate
    instance = MyClass(mocked_config)

    # Patch what should not be executed
    with patch.object(os.path, "exists", new=mocked_os_path_exist):

        if isinstance(expected_result, bool) and expected_result is False:
            with TestCase.assertRaises(instance, RuntimeError):
                content = instance.get_content()
        else:
            content = instance.get_content()

            assert content == expected_result

    # Asserting now that we used the expected functions
    mocked_config.get_param.assert_called_once_with("filename")
    mocked_os_path_exist.assert_called_once_with(filename)

Què tenim aquí?

  1. Ara no només tenim el decorador patch, també tenim el decorador parametrize, que defineix els noms dels paràmetres i els seus valors que li passarem al test case
  2. La definició de la funció del test case ara inclou paràmetres referents als fixtures i els referents a la parametrització
  3. El decorador patch està definit a nivell de la funció, i s'aplicarà sempre, encara que de fet només el fem servir quan comprovem que l'arxiu existeix
  4. Hi ha un if que comprova que el resultat esperat és de tipus bool i que és False. Bé, podriem haver afegit un paràmetre més que es digués is_runtime_error amb True o False per fer el mateix, però vaya, reutilitzem així variables. Aquest if farà que esperi o no un Error en cas que l'arxiu existeixi o no.

Executem el test: test_my_class_param

9. Cloenda

En aquest article he buscat un codi que em permeti introduir els conceptes dels que hi parlo, més que trobar un codi perfecte o veritablement útil. Deixeu-me que executi tots els tests a l'hora, per marcar-me un final maco: test_all

Al final ens hem de quedar amb unes poques afirmacions:

  • Els tests no són res més que codi que executa codi en base a paràmetres coneguts i que verifica que el resultat és l'esperat. Ni més ni menys.
  • Els tests unitaris han de testejar només el codi amb el que venen. Cal assegurar-se que el fluxe no executi parts externes o no desitjades.
  • Tenim èines ben potents per emular classes, objectes i mètodes, i que també permeten interferir en el funcionament habitual dels codi ja fet.
  • Hi ha moltes formes de testejar el mateix codi, no hi ha una veritat única.
  • No cal l'excel.lència en el codi de test. Cal simplificació i facilitat de manteniment. Quan el codi canvii el test ha de canviar també, cobrint la variació i la nova funcionalitat. Tingueu-ho en compte a l'hora d'abstraure o optimitzar un test en excés.
  • La febre pel 100% de cobertura dels tests no porta enlloc. Cobreix les parts que són importants, i mira de cobrir les variacions. En molts casos hi ha mòduls molt difícils de parchejar (hola datetime) i per conseguir el 100% t'hi estàs més temps del que paga la pena.

Algunes referències i èines que ajuden a testejar:

I per acabar, el codi que he utilitzat aquí, en cas que algú ho trobi interessant:

Previous Post Next Post