Mocking vs patching en Python

En este post veremos qué diferencias hay entre el mocking y el patching que, aunque relacionados, no son lo mismo. También cuando aplica uno y cuando el otro, y algunos de los inconvenientes que pueden llegar a surgir cuando los usemos.

En Python, no es necesario (a priori) instalar ningún paquete para hacer tests. La standard library ya viene de serie con todo lo necesario.

# a_test.py
import unittest
from unittest.mock import MagicMock


class FooTestCase(unittest.TestCase):
    def test_foo(self):
        mock_foo = MagicMock(return_value="Foo")
        self.assertEqual("Foo", mock_foo())

Para ejecutar el test bastaría ejecutar el comando python -m unittest a_test.

Este estilo de tests está inspirado en la familia de xUnit y, aunque es perfectamente válido, no es muy pythonico. Por eso, mucha gente opta por usar pytest. Se habla de pruebas funcionales vs pruebas basadas en clases.

#test_foo.py
from unittest.mock import MagicMock


def test_foo():
    mock_foo = MagicMock(return_value="Foo")
    assert "Foo" == mock_foo()

Con pytest test_foo.py podrás ejecutar el test.

Cabe mencionar que pytest (como test runner) puede ejecutar también tests creados con unittest.TestCase, por lo que pueden convivir ambos frameworks en el mismo proyecto.

Lo que en cualquier caso no cambia es la librería de mocking, que en ambas soluciones usa unittest.mock de la standard library.

En cuanto a las aserciones, puedes usar assert o instalar alguna otra librería de aserciones como assertpy.

#test_foo.py
from unittest.mock import MagicMock
from assertpy import assert_that


def test_foo():
    mock_foo = MagicMock(return_value="Foo")
    assert_that("Foo").is_equal_to(mock_foo())

La integración de pytest con VSCode y PyCharm es perfecta y no tendrás que instalar ningún plugin adicional.

Después de esta breve introducción, toca hablar de mocking y patching.

La primera pregunta que podría venirnos a la cabeza es ¿por qué tengo usar mocks?. Tienes que mockear una dependencia de la unidad/SUT que estás probando cuando quieres que se ejercite la unidad de forma aislada, cuando quieres controlar la ejecución del test con respuestas predefinidas que devuelvan lo que tú quieras, cuando te interesa preguntar cuál ha sido el uso que ha tenido la dependencia en el test (esto es si se ha llamado, con qué parámetros, etc.), cuando no quieras usar la implementación porque tendría un alto coste o directamente es inviable (en términos de tiempo, requisitos de infraestructura, etc.), cuando aun no esté disponible la implementación real (dependes de un componente de terceros que está desarrollándose o estás haciendo TDD outside-in), etc.

En líneas generales, quédate con que un mock es “un objeto simulado que imita el comportamiento de un objeto real de forma controlada”.

Esta explicación da para un post una serie de posts, por lo que te recomiendo leer alguno de los siguientes enlaces donde se cuenta las diferencias que hay entre los distintos dobles de tests: TestDouble, Test Double, Mocks Aren’t Stubs, Mocks Aren’t Stubs (traducción de Carlos Blé del artículo original de Martin Fowler).

En la práctica llamamos mock a cualquier doble de test porque se ha impuesto la palabra mock. Esto es porque muchos frameworks han sobreutilizado la palabra mock. Por ejemplo (y usando ejemplos principalmente de .NET), Moq, Rhino Mocks, FakeItEasy, o incluso el propio unittest.mock de Python. Todos ellos hablan de mocking. Aunque es cierto que hay diferencias entre los distintos tipos de dobles de test, en mi opinión resulta más práctico hablar de mocks que estar discutiendo sobre si el reemplazo es un dummy, un fake, un stub, un spy o un mock. A este respecto me gusta mucho lo que dice NSubstitute “Mock, stub, fake, spy, test double? Strict or loose? Nah, just substitute for the type you need!”

Habiendo explicado (muy por encima) el porqué necesitamos usar mocks y que distintos tipos hay, ¡usémoslos en Python!.

Lo primero que necesitamos es un SUT con algunas dependencias que queramos mockear. Usaremos el manido ejemplo de un servicio y un repositorio.

import abc
from dataclasses import dataclass
from typing import Iterable

import pyodbc
from assertpy import assert_that


@dataclass
class User:
    id: int
    name: str
    active: bool
    email: str


class UsersRepository(abc.ABC):
    @abc.abstractmethod
    def get_all(self, active: bool) -> Iterable[User]:
        pass


class DatabaseUsersRepository(UsersRepository):
    def __init__(self, connstring: str) -> None:
        self._connstring = connstring

    def get_all(self, active: bool) -> Iterable[User]:
        connection = pyodbc.connect(self._connstring)
        cursor = connection.cursor()
        sql = f"SELECT Id, Name, Email FROM Users WHERE Active = {'1' if active else '0'}"
        cursor.execute(sql)
        row = cursor.fetchone()
        users = []
        while row:
            users.append(User(row[0], row[1], True, row[2]))
            row = cursor.fetchone()
        return users


class EmailSender:
    def send(self, sender: str, recipient: str, body: str):
        # TODO Send email
        pass


class UsersService:
    def __init__(self, users_repository: UsersRepository, email_sender: EmailSender) -> None:
        self._users_repository = users_repository
        self._email_sender = email_sender

    def send_email_to_users_in_domain(self, recipient: str, domain_to_filter) -> Iterable[str]:
        sent_emails = []
        for user in self._users_repository.get_all(active=True):
            if not user.email.endswith(domain_to_filter):
                continue
            self._email_sender.send(user.email, recipient, f"Hello {user.name} in domain {domain_to_filter}")
            sent_emails.append(user.email)
        return sent_emails


def test_send_email_to_users_in_domain():
    connstring = f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER=(local);DATABASE=mocking;UID=sa;PWD=P@ssw0rd;"
    users_repository = DatabaseUsersRepository(connstring)
    email_sender = EmailSender()
    users_service = UsersService(users_repository, email_sender)

    actual = users_service.send_email_to_users_in_domain("no-reply@panicoenlaxbox.com", "gmail.com")

    assert_that(actual).is_equal_to(["panicoenlaxbox@gmail.com"])

En este ejemplo el SUT es el método UsersService.send_email_to_users_in_domain. Sin embargo, para poder ejecutar el test es necesario tener una BD con una tabla Users, donde además tiene que haber un sólo registro con un usuario activo y con el email panicoenlaxbox@gmail.com. Más adelante (cuando presumiblemente esté hecho el #TODO de EmailSender.send), tendríamos que configurar las credenciales para el envío de correo (y cruzar los dedos para que el servicio que estemos usando no esté caído o tenga algún tipo de throttling).

Es obvio que estamos frente a un test de integración de libro. Un test lento y que además parece nos demandará mucho esfuerzo de configuración previa. La idea original del test era probar UsersService.send_email_to_users_in_domain, no UsersRepository ni EmailSender.

Si quisiésemos de verdad ejecutar el test y que fuera FIRST, tendríamos que usar las fixtures de pytest para hacer un setup/cleanup en condiciones (crear la BD, reiniciar el estado de la tabla Users, insertar datos semilla, etc.).

¿Cómo lo hacemos entonces para testear sólo lo que queramos y que las dependencias de nuestro SUT no sean un problema? Ya lo sabes, mockeando.

Y es aquí donde te puedes encontrar (a grandes rasgos) dos distintos escenarios:

  • Si tu código usa inyección de dependencias…, !estás de enhorabuena!. Tú código es testeable y por eso reemplazar las dependencias por un mock debería de ser coser y cantar.
  • Si tu código no usa inyección de dependencias… !monkey patching al rescate!.
    • En Python está muy arraigada esta práctica, pero en otros lenguajes no está tan bien resuelta (por ejemplo en .NET hay librerías como Harmony o pose pero si puedes evitarlas, mejor).

Veamos en código las diferencias entre hacer mocking y patching.

Primero hagamos que nuestro test sea unitario de verdad usando mocking.

def test_send_email_to_users_in_domain():
    mock_users_repository = MagicMock()
    mock_users_repository.get_all.return_value = [User(1, "Sergio", True, "panicoenlaxbox@gmail.com")]
    mock_email_sender = MagicMock()
    users_service = UsersService(mock_users_repository, mock_email_sender)

    actual = users_service.send_email_to_users_in_domain("no-reply@panicoenlaxbox.com", "gmail.com")

    assert_that(actual).is_equal_to(["panicoenlaxbox@gmail.com"])

easy-peasy! Puesto que UsersService recibía por constructor las dependencias, reemplazarlas ha sido muy fácil.

Hagamos a continuación un pequeño cambio en el código de producción. Básicamente, prescindir de la D de SOLID.

class UsersService:
    def __init__(self) -> None:
        connstring = f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER=(local);DATABASE=mocking;UID=sa;PWD=P@ssw0rd;"
        self._users_repository = DatabaseUsersRepository(connstring)
        self._email_sender = EmailSender()

Ahora nuestro código ya no es tan testeable. Las dependencias son implícitas y no hay forma sencilla de cambiar el comportamiento de UsersService. Recuerda, new is glue.

Hacer pasar el test es sencillo… pero ahora estamos peor que antes, el código de producción es una caja negra y no parece ofrecer ningún punto de extensión.

def test_send_email_to_users_in_domain():
    users_service = UsersService()

    actual = users_service.send_email_to_users_in_domain("no-reply@panicoenlaxbox.com", "gmail.com")

    assert_that(actual).is_equal_to(["panicoenlaxbox@gmail.com"])

Para resolver esta incómoda situación, es donde el patching aparece en escena. La idea es reescribir nuestro código en tiempo de ejecución. Veámoslo con un ejemplo home-made.

old_open = open  # save original reference


def custom_open(path, mode):
    print(f"You are opening {path}")  # this is our extra code
    return old_open(path, mode)  # call to the original implementation


open = custom_open  # Monkey patching

with open("C:\\Temp\\foo.txt", "wt") as f:
    f.write("foo")

open = old_open  # restore original reference, unpatch

Usemos ahora patching para que nuestro test sea equivalente al test que usaba código de producción que sí era testeable.

def test_send_email_to_users_in_domain():
    with patch("tests.test_foo.DatabaseUsersRepository") as mock_database_users_repository:
        mock_database_users_repository.return_value = MagicMock(
            get_all=MagicMock(return_value=[User(1, "Sergio", True, "panicoenlaxbox@gmail.com")])
        )
        with patch("tests.test_foo.EmailSender"):
            users_service = UsersService()

            actual = users_service.send_email_to_users_in_domain("no-reply@panicoenlaxbox.com", "gmail.com")

            assert_that(actual).is_equal_to(["panicoenlaxbox@gmail.com"])

Como puedes ver, hemos hackeado nuestro código. Dentro del bloque with, el código del módulo tests.test_foo que use DatabaseUsersRepository, usará nuestro mock (porque hacer patch te devuelve un mock) en vez de la implementación real. ¡Salvados por la campana!

El lado oscuro del patching es que debes tener muy claro que estás parcheando. La regla de oro es que debes hacer patch “donde se esté usando lo que quieres parchear, no donde esté implementado”, puedes ampliar info aquí “The basic principle is that you patch where an object is looked up, which is not necessarily the same place as where it is defined”. Es decir, en nuestro caso sólo tenemos un módulo test_foo.py y allí tenemos tanto el código de producción como el código de test. Por eso el patch es sobre tests.test_foo.DatabaseUsersRepository y todo funciona. Si movemos el código de producción a la raíz del proyecto, ahora el patch dependerá de cómo importemos el módulo users_service.py en el módulo test_foo.py, que es donde se está usando.

from unittest.mock import MagicMock, patch

from assertpy import assert_that

from user import User
import users_service


def test_send_email_to_users_in_domain():
    with patch("tests.test_foo.users_service.DatabaseUsersRepository") as mock_database_users_repository:
        mock_database_users_repository.return_value = MagicMock(get_all=MagicMock(return_value=[
            User(1, "Sergio", True, "panicoenlaxbox@gmail.com")]))
        with patch("tests.test_foo.users_service.EmailSender"):
            users_service_ = users_service.UsersService()

            actual = users_service_.send_email_to_users_in_domain("no-reply@panicoenlaxbox.com", "gmail.com")

            assert_that(actual).is_equal_to(["panicoenlaxbox@gmail.com"])

Fíjate que después de mover todo el código de producción a la raíz del proyecto, hemos tenido que cambiar la forma de importar users_service y también la ruta de lo que queremos patchear, de tests.test_foo.DatabaseUsersRepository a tests.test_foo.users_service.DatabaseUsersRepository. Y si se usara DatabaseUsersRepository en algún otro sitio que no fuera UsersService tendríamos un problema porque eso no estaría parcheado y usaría la implementación original.

Otro problema es el tiempo de aplicación del parche. Cuando hagas un patch también tienes que tener muy claro cuando se hará el unpatch, porque si no tus tests van a tener dependencias entre ellos y empezarán los problemas.

Que puedas hacer algo no significa que debas. Salvo raras excepciones (y es mi opinión), si haces mucho patching es probable que tu diseño no sea el mejor.

Algunos casos donde creo el patching puede ser un salvavidas es cuando uses librerías que son un must-know, como por ejemplo:

Algo a tener en cuenta es la especificación del mock. En todos nuestros ejemplos (ya sea creando a mano el mock o usando patch), nuestro mock responde a cualquier llamada. Es un traga-bolas. Lógicamente esto no está bien porque, fácilmente, podría pasar que tuviéramos un test en verde con código de producción que no es correcto.

# test code
mock_users_repository = MagicMock()
mock_users_repository.get_all.return_value = [User(1, "Sergio", True, "panicoenlaxbox@gmail.com")]

# production code
for user in self._users_repository.get_all(active=True, a_non_existing_parameter=True):

En este ejemplo, el código de producción llama al método self._users_repository.get_all con el parámetro extra a_non_existing_parameter. El test pasa porque el mock no tiene problema con ello, pero el código de producción fallaría con TypeError: DatabaseUsersRepository.get_all() got an unexpected keyword argument 'a_non_existing_parameter'.

La conclusión es que mock = MagicMock() no basta y tenemos que ser un poco más estrictos a la hora de declarar nuestros mocks.

Trabajaremos sobre este ejemplo para ir refinándolo:

from unittest.mock import MagicMock


class Dog:
    def __init__(self, color: str):
        self.color = color

    def bark(self):
        print(f"A {self.color} dog is barking")


mock_dog = MagicMock()
mock_dog.bark()
mock_dog.bark("slow")  # bark does not receive a str argument, but it works
mock_dog.meow()  # meow works but it does not exist in Dog class
mock_dog.tweet = lambda: print(f"A dog is tweeting")  # We can add new methods to our mock
mock_dog.tweet()

En una primera aproximación podríamos usar spec, spec_set y seal.

Con spec podemos pasarle una lista de strings, una clase o un objeto. En cualquier caso, lo que estamos haciendo es crear una especificación en el mock, por lo que, si llamamos a un método que no existe, el mock fallará. Como contrapartida, spec no valida parámetros y podemos seguir añadiendo métodos al mock.

# spec
mock_dog = MagicMock(spec=Dog)
mock_dog.bark()
mock_dog.bark("slow")  # 😒
# mock_dog.meow() # AttributeError: Mock object has no attribute 'meow'
mock_dog.tweet = lambda: print(f"A dog is tweeting")
mock_dog.tweet()

Con spec_set subimos un poco el nivel y ahora no podremos agregar métodos al mock.

mock_dog = MagicMock(spec_set=Dog)
mock_dog.bark()
mock_dog.bark("slow")  # 😒
# mock_dog.meow() # AttributeError: Mock object has no attribute 'meow'
# mock_dog.tweet = lambda: print(f"A dog is tweeting")  # AttributeError: Mock object has no attribute 'tweet'
# mock_dog.tweet()

Con seal, nos ponemos un poco más hardcore y sólo podremos llamar a métodos que hayan sido configurados previamente.

mock_dog = MagicMock(spec_set=Dog)
seal(mock_dog)
# mock_dog.bark()  # AttributeError: mock.bark
# ...

Si queremos usar el mock toca configurarlo:

mock_dog = MagicMock(spec_set=Dog)
mock_dog.bark.side_effect = lambda: print("Barking from a mock")
# mock_dog.tweet = lambda: print(f"A dog is tweeting")  # AttributeError: Mock object has no attribute 'tweet'
seal(mock_dog)
mock_dog.bark()
# mock_dog.bark("slow")  # TypeError: <lambda>() takes 0 positional arguments but 1 was given
# mock_dog.meow()  # AttributeError: Mock object has no attribute 'meow'
# mock_dog.tweet()

En este punto mi recomendación es (aunque luego diré lo contrario) usar spec_set y seal.

Algo que seguro te dará guerra son las propiedades de instancia creadas en el constructor inicializador.

from unittest.mock import MagicMock


class Dog:
    def __init__(self, color: str):
        self.color = color

    def bark(self):
        print(f"A {self.color} dog is barking")


mock_dog = MagicMock(spec_set=Dog)
print(mock_dog.color)  # AttributeError: Mock object has no attribute 'color'

Esto sucede porque MagicMock no ejecuta el código de __init__, luego no sabe que existe la propiedad color.

¿Qué podemos hacer?

  • Añadir estas propiedades a mano en el mock, pero entonces no podríamos usar spec_set.
  • Pasar las variables de instancia a variables de clase… no es una solución.
  • Pasar los atributos de instancia a @property y usar PropertyMock en la configuración del mock.
from unittest.mock import MagicMock, seal, PropertyMock


class Dog:
    def __init__(self, color: str):
        self.color = color

    def bark(self):
        print(f"A {self.color} dog is barking")

    @property
    def color(self):
        return self._color

    @color.setter
    def color(self, value):
        self._color = value


mock_dog = MagicMock(spec_set=Dog)
type(mock_dog).color = PropertyMock(return_value="brown")
seal(mock_dog)
print(mock_dog.color)  # brown

¡Se está complicando crear un mock con garantias!

La última alternativa que tenemos (y probablemente la mejor) es usar autospeccing.

from unittest.mock import PropertyMock, create_autospec


class Dog:
    def __init__(self, color: str):
        self.color = color

    def bark(self):
        print(f"A {self.color} dog is barking")

    @property
    def color(self):
        return self._color

    @color.setter
    def color(self, value):
        self._color = value


mock_dog = create_autospec(spec=Dog, spec_set=True, instance=True)
mock_dog.bark.side_effect = lambda: print("Barking from a mock")
type(mock_dog).color = PropertyMock(return_value="brown")
mock_dog.bark()  # Barking from a mock
print(mock_dog.color)  # brown

Dicho todo esto, tengo que reconocer que muchas veces adora la simpleza de mock = MagicMock() y termino relajando la especificación del mock en aras de simplificar el setup del test. Cierto es que estoy añadiendo un punto de fallo a mis tests, pero a veces compro deliberadamente este riesgo.

¡Un saludo!


Ver también