MagicMockers que tan mágicos son?

El objetivo principal de un test unitario es probar de manera aislada el comportamiento de una función o de un método sin invocar recursos de terceros, de lo contrario entraríamos en otro nivel de la pirámide de test llamado test de integración Ver Figura 1, para lograr esto necesitamos de algunos conceptos como mocks, stubs, fakers y dummies los cuales usamos para simular comportamientos y respuestas de información, en Python tenemos dos principales frameworks para soportar nuestras pruebas, estos son unitest y pytest

Figura 1: Pirámide de pruebas Obtenido de: https://www.linkedin.com/posts/alexpushkarev_testclub-testautomation-softwaredesign-activity-6911409054846185472-DHhH?utm_source=linkedin_share&utm_medium=member_desktop_web

Unitest y pytest

Unitest es el framework nativo de pruebas en python, este es basado en Junit y por lo tanto, es totalmente orientado a objetos, por otro lado, tenemos pytest, más pythonico y más idiomático, orientado a escribir pruebas no solamente con clases, sino también con funciones, tiene algunos sets muy interesantes como pytest-mock,  y a pesar de que no es el nativo es muy usado con más de 8k estrellas en Github Ver Figura 2, una de las cosas interesantes aquí, es que mucha de la documentación de mockers se encuentra precisamente en unitest, afortunadamente la mayoría de funciones de mocks  se logran hacer en pytest, esto como siempre digo no es tan amigable y tan lógico de traducir, viene mucho de probar y fallar, en este post nos enfocaremos en MagicMocker y lo que se puede hacer con pytest

Figura 2: Estrellas de pytest Obtenida de https://star-history.com/#pytest-dev/pytest&Date

MagicMocker en pytest

Unitest en Python proporciona una librería llamada mock, esta libreria la usamos para reemplazar partes del producto y enfocar la prueba en lo que necesito, si usaremos pytest porque mencionar unitest? Bueno, pytest propiamente tiene un fixture que permite modificar algunas invocaciones globales llamadas monkeypath, pero es muy limitado, por ello debemos utilizar una librería adicional llamada pytest_mock que nace a partir de esa librería nativa de unitest.mock para ello la vamos a instalar

poetry add pytest-mock

luego vamos a crear las siguientes clases de contexto dentro del módulo src/

from dataclasses import dataclass


@dataclass
class User:
    name:str
    last_name:str
    age:int
    email:str
user.py
from src.user import User
from typing import Union

users = [
    User(name='jairo andres', last_name='castañeda pacheco', age=25, email='me@jairoandres.com')
]


class GetUserByEmail:
    @staticmethod
    def run(email:str) -> Union[User, None]:
        for user in users:
            if user.email == email:
                return user
get_user.py
from src.get_user import GetUserByEmail, users
from src.user import User
from src.api_metric import log_user_email_already_exists, log_user_registered


class CreateUser:
    @staticmethod
    def run(name:str, age:int, email:str, last_name:str ):
        user = GetUserByEmail.run(email)
        if user:
            log_user_email_already_exists(email)
            print(f'User with email {email} already exists')
        else:
            user = User(name=name,
                        age=age,
                        email=email,
                        last_name=last_name)

            users.append(user)

            log_user_registered(user)
            print("User created!")

create_user.py
import os
import requests
from dataclasses import asdict
from src.user import User


def log_user_registered(user:User):
    requests.post(url=os.getenv('API'), data=asdict(user))


def log_user_email_already_exists(email:str):
    requests.post(url=os.getenv('API'), data={'email':email})
api_metric.py

La idea es poder probar este código usando magick mocker  de diferentes formas, así que realizaremos el primer test en el folder tests/

from pytest_mock import MockFixture
from src.create_user import CreateUser
from src.user import User
import pytest


class GetUserStub:
    @staticmethod
    def run(*args, **kwargs):
        return User(
            last_name='test',
            email='test',
            age=1,
            name='test'
        )

def test_create_user_already_exists_called_api(mocker:MockFixture):
    mocker.patch('src.create_user.GetUserByEmail', GetUserStub)
    mock_log_user_email_already_exists = mocker.MagicMock()
    mock_log_user_registered = mocker.MagicMock()
    mocker.patch('src.create_user.log_user_email_already_exists', mock_log_user_email_already_exists)
    mocker.patch('src.create_user.log_user_registered', mock_log_user_registered)
    CreateUser.run(name="Johnny", last_name="Depp", email='j@depp.com', age=58)
    mock_log_user_email_already_exists.assert_called()

ejecutamos los test con el comando

poetry run pytest -s -vv tests/
Figura 3: Ejecución de los test

el test anterior se enfoca en probar la creación de un usuario, pero si se fijan la creación de un usuario llama una API de terceros que se encarga de informar que el usuario que se registró, este método no es que no quiera llamarlo, de hecho en eso consiste el test, lo que deseo es que no invoque la API real, por ello no solo mockeo la llamada, sino que le agrego un MagickMock, este lo que hace es de manera mágica agregarle a ese objeto mágico todos los métodos y atributos que se le invoquen en ejecución, y después permite dentro del test saber que métodos y atributos se llamaron, al final del test yo válido con un assert_called() esto quiere decir que devuelva verdadero si ese objeto falso que pase fue invocado por lo menos una vez, veamos otro ejemplo de otro test

def test_create_user_already_exists_called_api_with_email(mocker:MockFixture):
    mocker.patch('src.create_user.GetUserByEmail', GetUserStub)
    mock_log_user_email_already_exists = mocker.MagicMock()
    mock_log_user_registered = mocker.MagicMock()
    mocker.patch('src.create_user.log_user_email_already_exists', mock_log_user_email_already_exists)
    mocker.patch('src.create_user.log_user_registered', mock_log_user_registered)
    CreateUser.run(name="Johnny", last_name="Depp", email='t@test.com', age=58)
    mock_log_user_email_already_exists.assert_called_with(
        't@test.com'
    )

En el test anterior lo que realizo es muy similar al primero, pero uso otro método del magick mocker llamado  assert_called_with() este método permite saber con qué parámetros fue llamado la funcion la falsa que tengo, de esta manera en el test aseguro que a la API que invoco le llega el parámetro que debería, ahora con otro ejemplo vamos a darle un poco de complejidad, el ejemplo que tenemos de contexto es muy sencillo, pero imaginemos que en el GetUserByEmail, ahora queremos validar que parámetro llega en el método run() suponiendo o imaginándonos que en el código hay más lógica y que ese parámetro podría perderse y enviarse otro, aquí hay que mezclar un poco el concepto de stub y mock, veamos el siguiente código

@pytest.fixture(autouse=True)
def mocks_apis(mocker:MockFixture):
    mock_log_user_email_already_exists = mocker.MagicMock()
    mock_log_user_registered = mocker.MagicMock()
    mocker.patch('src.create_user.log_user_email_already_exists', mock_log_user_email_already_exists)
    mocker.patch('src.create_user.log_user_registered', mock_log_user_registered)
    
def test_create_user_already_exists_called_get_user_with_email(mocker:MockFixture):
    mock_get_user_by_email = mocker.MagicMock()
    mocker.patch('src.create_user.GetUserByEmail', mock_get_user_by_email)
    CreateUser.run(name="Johnny", last_name="Depp", email='t@test.com', age=58)
    mock_get_user_by_email.run.assert_called_with(
        't@test.com'
    )

Nota: Se crea un fixture llamado mocks_apis que mockea las API de terceros para reutilizar el código y no repetirlo en las demás funciones que invoquen le CreateUser, pero que no necesite ninfo de estos mocks

En el código anterior se mockea la clase GetUserByEmail, en el flujo se llama a un método run de esta clase, que es el que nos interesa, por eso el assert apunta al método run que se 'crea en ejecución', como se ve en la siguiente línea de código

 mock_get_user_by_email.run.assert_called_with(
        't@test.com'
    )

es importante saber que todos los metodos que se llama de ese objeto facker, heredan o implementado los métodos de validación que empiezan por assert_* y call_*. Ahora imaginemos que queremos probar que el usuario efectivamente se crea y saber  además si el valor que se envía a la API de terceros es correcto, veamos un poco el código de ese ejemplo

def test_create_user_success_called_api_with_user(mocker:MockFixture):
    mock_get_user_by_email = mocker.MagicMock()
    mock_get_user_by_email.run.return_value = None
    mock_log_user_registered = mocker.MagicMock()
    mocker.patch('src.create_user.log_user_registered', mock_log_user_registered)
    mocker.patch('src.create_user.GetUserByEmail', mock_get_user_by_email)
    CreateUser.run(name="Johnny", last_name="Depp", email='t@test.com', age=58)
    args, kwargs = mock_log_user_registered.call_args
    user: User = kwargs['user']
    assert user.name == 'Johnny'

En el código anterior se utiliza el mocker de GetUserByEmail para que retorne nulo, esto se hace para que el flujo continúe, puede darse mucho cuando hay validación y excepciones si esto no se hace puede ocurrir una excepción que rompa el flujo, de esta forma ahora el flujo se dirige a registrar el usuario correctamente, pero aquí recibe como parámetro un objeto que se construye dentro de la función o sea la creación de una instancia de User, entonces no se tiene la referencia, por lo tanto, no se puede utilizar el assert_called_with, se debe obtener el parámetro con el que se llamó la función y después con ese valor realizar la validación, para ello se utiliza el atributo call_args que permite obtener los parámetros con los que se invocó la función, de aquí se obtiene el parámetro user, el cual es de tipo User y sé válida el atributo name.

Estas situaciones suelen presentarse en proyectos grandes, que se pasen más de un parámetro y además complejos, donde el que realmente es importante es uno solo (para el test en concreto), de esta manera se logra utilizar el MagicMock para ahorrar código en escribir de pronto stubs innecesarios o complejos

Conclusiones

MagickMock es una librería genial, ahorra escribir más código del necesario en los test, que dependiendo de la complejidad, pueden requerir bastante tiempo y trabajo, pero que a veces con un magick mock se logra suplir la necesidad, adicional  ello, todos sus métodos para realizar asserts y validar parámetros, y sin duda alguna cuando el test es un poco 'deep' usar un mock magico brinda una gran capacidad de escribir test más legibles

¿Te gusto el post y quisieras poder aplicar lo aprendido?

Únete al equipo de Simetrik AQUI