HOWTO · Python

Aplicación de parches de mono en Python

Este artículo explica qué es el parche mono y cómo se puede implementar en Python. Tiene dos secciones; el primero demuestra la aplicación de parches mono en funciones, y el segundo muestra cómo se puede implementar en las pruebas unitarias de archivos.

En esta página

El monkey patching cambia o añade un atributo mientras se ejecuta un programa. En Python, asignar una función a una clase o reemplazar un nombre de un módulo cambia lo que encontrarán las búsquedas de atributos posteriores. Esto resulta útil para pruebas específicas, aunque también puede hacer que el comportamiento dependa de cuándo y dónde se realiza el cambio.

Qué significa el monkey patching en Python

Python permite que los programas asignen atributos a muchos objetos durante la ejecución. Por lo general, el monkey patching consiste en cambiar un atributo existente o añadir uno para que el código use un comportamiento de reemplazo. No requiere modificar el archivo fuente original y no es una técnica exclusiva de los lenguajes dinámicos: otros lenguajes pueden ofrecer mecanismos comparables mediante sus propias herramientas de ejecución o de pruebas.

En esta guía se presentan dos casos relacionados, pero distintos. Asignar una función a una clase añade comportamiento a las instancias de esa clase. Reemplazar una variable global de un módulo cambia el valor que lee al ejecutarse una función de ese módulo. Este segundo patrón suele ser útil en pruebas cuando el código de producción utiliza una ruta configurada o un colaborador.

Añadir un método a una clase durante la ejecución

Cuando se asigna una función normal a una clase, el comportamiento de descriptor de Python vincula la instancia como primer argumento de la función al acceder a ella a través de esa instancia. Define la función con self y luego asígnala a la clase. Esto modifica el objeto de clase existente; no crea una clase nueva.

Al asignar una función a una clase, esta se almacena como descriptor. Al acceder a ella a través de una instancia, dicha instancia queda vinculada como primer argumento; por eso, define un parámetro para self. Ejecuta este ejemplo:

El código y las etiquetas o salidas visibles de esta sección se mantienen en inglés.

class Greeter:
    pass


def greet(self, name):
    return f"Hello, {name}!"


Greeter.greet = greet
print(Greeter().greet("Ada"))
Hello, Ada!

La asignación afecta a Greeter y a sus instancias durante el proceso actual. Si, en cambio, se asigna una función a una sola instancia, queda almacenada en esa instancia y no recibe automáticamente la misma vinculación de método que una función encontrada en la clase. Asigna la función a la clase cuando quieras que todas sus instancias vean el método añadido.

Reemplazar una variable global de módulo en una prueba de pytest

Supongamos que pipeline.py define DATA_DIR = Path(__file__).parent / "data" y que su función collect_files(pattern) devuelve list(DATA_DIR.glob(pattern)). Cuando se llama, la función busca DATA_DIR en el módulo pipeline. Crear un directorio temporal en una prueba no redirige por sí solo esa búsqueda; por tanto, la prueba debe reemplazar el nombre en el módulo donde la función lo busca.

En una prueba de pytest, tmp_path proporciona un objeto Path temporal. El ejemplo siguiente define un módulo pipeline pequeño cuya función collect_files busca DATA_DIR en el espacio de nombres de ese módulo. Su directorio de datos habitual está vacío, mientras que el directorio de la prueba contiene file1.txt. Antes de aplicar el parche, assert pipeline.collect_files("*.txt") == [expected_file] fallaría: la comparación sería [] == [expected_file]. Después de usar monkeypatch.setattr, la función devuelve el archivo del directorio de la prueba. Esto describe el fallo determinista para la configuración mostrada; no es el resultado de una ejecución registrada de pytest.

El código y las etiquetas o salidas visibles de esta sección se mantienen en inglés.

from pathlib import Path
import tempfile
import types


pipeline = types.ModuleType("pipeline")
production_data = tempfile.TemporaryDirectory()
pipeline.DATA_DIR = Path(production_data.name)
exec(
    "def collect_files(pattern):\n"
    "    return list(DATA_DIR.glob(pattern))\n",
    pipeline.__dict__,
)


def test_collect_files_uses_temporary_data(tmp_path, monkeypatch):
    test_data = tmp_path / "data"
    test_data.mkdir()
    expected_file = test_data / "file1.txt"
    expected_file.write_text("sample", encoding="utf-8")

    files_before_patch = pipeline.collect_files("*.txt")
    assert files_before_patch == []

    monkeypatch.setattr(pipeline, "DATA_DIR", test_data)
    assert pipeline.collect_files("*.txt") == [expected_file]

El destino del parche importa: aplica el parche a pipeline.DATA_DIR, porque collect_files busca esa variable global del módulo. Cambiar una variable con el mismo nombre en el módulo de la prueba no modifica la variable global que usa pipeline.collect_files. El nombre del fixture es monkeypatch, y monkeypatch.setattr es la operación que reemplaza el atributo. Pytest restaura el valor original al terminar la prueba, incluso si esta falla, lo que ayuda a limitar el cambio a esa prueba. El fixture administra el reemplazo; no convierte automáticamente los archivos o el directorio temporal de la prueba en el directorio de datos del módulo.

Usar el monkey patching con cuidado

El monkey patching resulta útil cuando una prueba necesita reemplazar una dependencia o redirigir una configuración del módulo sin cambiar el flujo de llamadas de producción. Mantén el reemplazo dentro de la prueba, apunta al nombre que realmente lee el código y prefiere el fixture monkeypatch de pytest para que el atributo original se restaure al terminar la prueba.

Los cambios en clases o módulos compartidos pueden afectar a código no relacionado que se ejecute después, y el resultado puede depender del orden de importación o de las pruebas. Evita los parches permanentes para toda la aplicación cuando una configuración habitual, la inyección de dependencias o una pequeña función envolvente permitan hacer explícita la dependencia. Un parche de clase es adecuado cuando el método añadido debe estar disponible para las instancias de esa clase; un parche de una variable global de módulo lo es cuando una función lee un nombre del módulo que la prueba necesita reemplazar. Estas distinciones facilitan el diagnóstico de fallos y reducen las sorpresas.