19. Mocking con pytest: monkeypatch, fixtures y parametrización

19.1 Objetivo del tema

pytest ofrece herramientas muy útiles para pruebas con dobles. Una de ellas es monkeypatch, una fixture integrada que permite reemplazar atributos, variables de entorno y elementos de diccionarios de forma temporal.

En este tema veremos cómo usar monkeypatch, cómo combinarlo con fixtures propias y cómo parametrizar pruebas para cubrir varios escenarios sin duplicar código.

Objetivo práctico: usar herramientas de pytest para reemplazar dependencias y organizar pruebas con dobles de forma clara.

19.2 Qué es monkeypatch

monkeypatch es una fixture de pytest. No hay que importarla: se pide como parámetro en la prueba.

def test_algo(monkeypatch):
    ...

Permite modificar temporalmente el entorno de ejecución. Al terminar la prueba, pytest revierte los cambios automáticamente.

19.3 monkeypatch.setattr

setattr reemplaza un atributo de un módulo, clase u objeto. Es parecido a patch, pero con estilo de pytest.

Supongamos tienda/codigos.py:

from uuid import uuid4


def crear_codigo_pedido():
    return f"PED-{uuid4()}"

Podemos reemplazar uuid4 así:

import tienda.codigos as codigos


def test_crear_codigo_pedido(monkeypatch):
    monkeypatch.setattr(codigos, "uuid4", lambda: "ABC123")

    assert codigos.crear_codigo_pedido() == "PED-ABC123"

19.4 Ventaja de usar el módulo importado

En el ejemplo anterior importamos el módulo completo como codigos. Eso deja muy claro qué atributo estamos reemplazando:

monkeypatch.setattr(codigos, "uuid4", lambda: "ABC123")

La misma regla de patch sigue vigente: reemplazamos el nombre usado por el módulo bajo prueba.

19.5 monkeypatch con función fake

Podemos reemplazar una función real por una función fake:

def uuid_fijo():
    return "ABC123"


def test_crear_codigo_pedido_con_funcion_fake(monkeypatch):
    monkeypatch.setattr(codigos, "uuid4", uuid_fijo)

    assert codigos.crear_codigo_pedido() == "PED-ABC123"

Esto es claro cuando no necesitamos verificar llamadas. Si necesitamos verificar llamadas, podemos usar un Mock.

19.6 monkeypatch con Mock

También podemos reemplazar por un mock:

from unittest.mock import Mock


def test_crear_codigo_pedido_con_mock(monkeypatch):
    uuid4_mock = Mock(return_value="ABC123")
    monkeypatch.setattr(codigos, "uuid4", uuid4_mock)

    assert codigos.crear_codigo_pedido() == "PED-ABC123"
    uuid4_mock.assert_called_once_with()

Esta combinación es útil cuando queremos usar el estilo de restauración de pytest y las aserciones de Mock.

19.7 monkeypatch.setenv

setenv modifica variables de entorno durante la prueba. Supongamos:

import os


def obtener_modo():
    return os.getenv("APP_MODE", "prod")

Prueba:

def test_obtener_modo_desde_variable_de_entorno(monkeypatch):
    monkeypatch.setenv("APP_MODE", "test")

    assert obtener_modo() == "test"

Al terminar la prueba, pytest restaura el entorno.

19.8 monkeypatch.delenv

Para simular que una variable no existe, usamos delenv:

def test_obtener_modo_por_defecto(monkeypatch):
    monkeypatch.delenv("APP_MODE", raising=False)

    assert obtener_modo() == "prod"

raising=False evita que la prueba falle si la variable no estaba definida.

19.9 monkeypatch.setitem

También se pueden modificar diccionarios temporalmente:

CONFIG = {
    "moneda": "USD",
}


def obtener_moneda():
    return CONFIG["moneda"]

Prueba:

def test_obtener_moneda(monkeypatch):
    monkeypatch.setitem(CONFIG, "moneda", "ARS")

    assert obtener_moneda() == "ARS"

19.10 Fixtures propias con dobles

Podemos crear fixtures para stubs, fakes o mocks usados en varias pruebas:

import pytest


class RepositorioClientesStub:
    def __init__(self, cliente):
        self.cliente = cliente

    def buscar_por_id(self, cliente_id):
        return self.cliente


@pytest.fixture
def cliente_vip():
    return {"id": 1, "nombre": "Ana", "categoria": "vip"}

Luego usamos la fixture en la prueba.

19.11 Fixture que construye un stub

Ejemplo:

@pytest.fixture
def repositorio_cliente_vip(cliente_vip):
    return RepositorioClientesStub(cliente_vip)


def test_cliente_vip_tiene_descuento(repositorio_cliente_vip):
    descuento = calcular_descuento(1, 1000, repositorio_cliente_vip)

    assert descuento == 200

Las fixtures ayudan a nombrar escenarios comunes, pero no conviene esconder datos importantes en exceso.

19.12 Parametrización

pytest.mark.parametrize permite ejecutar la misma prueba con varios datos:

import pytest


@pytest.mark.parametrize(
    "categoria,total,descuento_esperado",
    [
        ("vip", 1000, 200),
        ("frecuente", 1000, 100),
        ("comun", 1000, 0),
    ],
)
def test_calcular_descuento_por_categoria(categoria, total, descuento_esperado):
    repositorio = RepositorioClientesStub({
        "id": 1,
        "nombre": "Ana",
        "categoria": categoria,
    })

    descuento = calcular_descuento(1, total, repositorio)

    assert descuento == descuento_esperado

Esto evita duplicar pruebas muy parecidas.

19.13 Parametrizar errores

También podemos parametrizar escenarios de error:

def validar_total(total):
    if total <= 0:
        raise ValueError("El total debe ser mayor que cero")
    return total


@pytest.mark.parametrize("total_invalido", [0, -1, -100])
def test_validar_total_rechaza_valores_invalidos(total_invalido):
    with pytest.raises(ValueError):
        validar_total(total_invalido)

La parametrización es útil cuando cambia el dato, pero la intención de la prueba es la misma.

19.14 monkeypatch frente a patch

Ambas herramientas permiten reemplazar dependencias temporalmente. Algunas diferencias prácticas:

  • patch es parte de unittest.mock y crea mocks automáticamente.
  • monkeypatch es una fixture de pytest y puede reemplazar por cualquier objeto.
  • patch se usa mucho con context managers y decoradores.
  • monkeypatch se integra naturalmente con fixtures de pytest.

19.15 Cuándo usar monkeypatch

monkeypatch suele ser cómodo cuando:

  • Quieres reemplazar una función por una función fake simple.
  • Necesitas modificar variables de entorno.
  • Necesitas cambiar un diccionario o atributo de módulo.
  • Ya estás usando fixtures de pytest para armar el escenario.

Si necesitas muchas aserciones de llamadas, puedes combinarlo con Mock.

19.16 Evitar monkeypatch innecesario

Si el código ya permite inyectar la dependencia por parámetro, no hace falta usar monkeypatch:

def crear_codigo(generar_token):
    return f"COD-{generar_token()}"

La prueba puede pasar una función directamente:

def test_crear_codigo_sin_monkeypatch():
    assert crear_codigo(lambda: "ABC") == "COD-ABC"

Usa la herramienta más simple que resuelva el problema.

19.17 Ejercicio práctico

Este código está en seguridad/tokens.py:

from secrets import token_hex


def crear_token():
    return token_hex(4)

Escribe una prueba con monkeypatch para que crear_token() devuelva "abcd". Luego escribe otra usando Mock para verificar que token_hex fue llamado con 4.

19.18 Solución posible del ejercicio

Solución con función fake:

import seguridad.tokens as tokens


def test_crear_token_con_monkeypatch(monkeypatch):
    monkeypatch.setattr(tokens, "token_hex", lambda cantidad: "abcd")

    assert tokens.crear_token() == "abcd"

Solución con Mock:

from unittest.mock import Mock

import seguridad.tokens as tokens


def test_crear_token_verifica_llamada(monkeypatch):
    token_hex_mock = Mock(return_value="abcd")
    monkeypatch.setattr(tokens, "token_hex", token_hex_mock)

    assert tokens.crear_token() == "abcd"
    token_hex_mock.assert_called_once_with(4)

19.19 Conclusión

pytest facilita el trabajo con dobles mediante monkeypatch, fixtures y parametrización. Estas herramientas permiten reemplazar dependencias, preparar escenarios reutilizables y cubrir varios casos con menos duplicación.

En el próximo tema veremos cómo controlar variables de entorno y configuración en las pruebas con más profundidad.