26. Mockear funciones, métodos y dependencias externas

26.1 Objetivo del tema

En el tema anterior usamos Mock como dependencia inyectada. Pero muchas veces el código ya importa funciones o clases desde otros módulos. En esos casos usamos patch para reemplazar temporalmente una función, un método o una dependencia externa.

En este tema veremos cómo aplicar patch, dónde aplicarlo y cómo verificar llamadas.

Idea clave: se mockea el nombre que usa el módulo bajo prueba, no necesariamente el módulo donde la función fue definida originalmente.

26.2 Crear una carpeta de práctica

Crea un proyecto nuevo:

mkdir pytest-patch-demo
cd pytest-patch-demo

unittest.mock viene incluido con Python. Para ejecutar los ejemplos, instala pytest si no está disponible:

python -m pip install pytest

26.3 Crear una dependencia externa simulada

Crea un archivo llamado integraciones.py:

def obtener_cotizacion_dolar():
    raise RuntimeError("No llamar a la cotización real durante una prueba")


def enviar_email(destinatario, asunto, cuerpo):
    raise RuntimeError("No enviar emails reales durante una prueba")

Estas funciones representan dependencias externas. En una aplicación real podrían llamar a una API o a un servicio de correo.

26.4 Crear el módulo a probar

Crea un archivo llamado ventas.py:

from integraciones import enviar_email, obtener_cotizacion_dolar


def convertir_a_dolares(total_pesos):
    cotizacion = obtener_cotizacion_dolar()
    return round(total_pesos / cotizacion, 2)


def confirmar_compra(cliente, total_pesos):
    total_usd = convertir_a_dolares(total_pesos)
    enviar_email(
        cliente["email"],
        "Compra confirmada",
        f"Total en dólares: {total_usd}",
    )
    return {
        "cliente": cliente["nombre"],
        "total_usd": total_usd,
        "estado": "confirmada",
    }


def calcular_total_con_envio(total_pesos, servicio_envios, codigo_postal):
    costo_envio = servicio_envios.calcular_costo(codigo_postal)
    return total_pesos + costo_envio


class Facturador:
    def generar_numero(self):
        raise RuntimeError("No generar número real durante una prueba")

    def crear_factura(self, cliente, total):
        numero = self.generar_numero()
        return {
            "numero": numero,
            "cliente": cliente,
            "total": total,
        }

El módulo importa funciones externas y también tiene una clase con un método que queremos reemplazar en una prueba.

26.5 Mockear una función con patch

Para reemplazar obtener_cotizacion_dolar durante la prueba usamos patch como administrador de contexto:

from unittest.mock import patch

from ventas import convertir_a_dolares


def test_convertir_a_dolares():
    with patch("ventas.obtener_cotizacion_dolar", return_value=1000):
        resultado = convertir_a_dolares(25000)

    assert resultado == 25

La función real no se ejecuta. Durante el bloque with, ventas.obtener_cotizacion_dolar devuelve 1000.

26.6 Dónde aplicar patch

En ventas.py se hizo este import:

from integraciones import enviar_email, obtener_cotizacion_dolar

Por eso el código bajo prueba usa el nombre ventas.obtener_cotizacion_dolar. El patch correcto es:

patch("ventas.obtener_cotizacion_dolar", return_value=1000)

No alcanza con reemplazar integraciones.obtener_cotizacion_dolar, porque ventas.py ya tiene su propia referencia importada.

Regla práctica: aplica patch donde la dependencia se usa, no donde nació.

26.7 Verificar que la función fue llamada

El objeto devuelto por patch es un mock. Podemos verificar llamadas:

def test_convertir_a_dolares_consulta_cotizacion():
    with patch("ventas.obtener_cotizacion_dolar", return_value=500) as cotizacion_mock:
        resultado = convertir_a_dolares(1000)

    assert resultado == 2
    cotizacion_mock.assert_called_once_with()

Esta prueba comprueba el resultado y también que se consultó la cotización.

26.8 Mockear varias funciones

Para probar confirmar_compra, reemplazamos la cotización y el envío de email:

from ventas import confirmar_compra


def test_confirmar_compra_envia_email():
    cliente = {
        "nombre": "Ana",
        "email": "ana@example.com",
    }

    with patch("ventas.obtener_cotizacion_dolar", return_value=1000), \
         patch("ventas.enviar_email") as enviar_email_mock:
        resultado = confirmar_compra(cliente, 30000)

    assert resultado == {
        "cliente": "Ana",
        "total_usd": 30,
        "estado": "confirmada",
    }
    enviar_email_mock.assert_called_once_with(
        "ana@example.com",
        "Compra confirmada",
        "Total en dólares: 30.0",
    )

El email real no se envía. Solo verificamos que el código intentó enviarlo con los argumentos correctos.

26.9 Simular errores de una dependencia

Podemos usar side_effect para simular una falla externa:

import pytest


def test_confirmar_compra_si_cotizacion_falla_no_envia_email():
    cliente = {
        "nombre": "Ana",
        "email": "ana@example.com",
    }

    with patch("ventas.obtener_cotizacion_dolar", side_effect=RuntimeError), \
         patch("ventas.enviar_email") as enviar_email_mock:
        with pytest.raises(RuntimeError):
            confirmar_compra(cliente, 30000)

    enviar_email_mock.assert_not_called()

La prueba confirma que no se envía email si antes falló la cotización.

26.10 Mockear un método de un objeto

Si una función recibe un objeto, podemos reemplazar uno de sus métodos con Mock:

from unittest.mock import Mock

from ventas import calcular_total_con_envio


def test_calcular_total_con_envio():
    servicio_envios = Mock()
    servicio_envios.calcular_costo.return_value = 1500

    resultado = calcular_total_con_envio(10000, servicio_envios, "5000")

    assert resultado == 11500
    servicio_envios.calcular_costo.assert_called_once_with("5000")

Este caso no necesita patch porque la dependencia llega por parámetro.

26.11 Mockear un método de clase con patch.object

Para reemplazar un método de una clase podemos usar patch.object:

from ventas import Facturador


def test_crear_factura_con_numero_mockeado():
    facturador = Facturador()

    with patch.object(Facturador, "generar_numero", return_value="F-001") as numero_mock:
        factura = facturador.crear_factura("Ana", 12000)

    assert factura == {
        "numero": "F-001",
        "cliente": "Ana",
        "total": 12000,
    }
    numero_mock.assert_called_once_with()

Mientras dura el bloque, el método generar_numero queda reemplazado.

26.12 Usar patch como decorador

patch también puede usarse como decorador de una función de prueba:

@patch("ventas.obtener_cotizacion_dolar", return_value=2000)
def test_convertir_a_dolares_con_decorador(cotizacion_mock):
    resultado = convertir_a_dolares(10000)

    assert resultado == 5
    cotizacion_mock.assert_called_once_with()

El mock se recibe como parámetro de la prueba. Para ejemplos simples, el bloque with suele ser más explícito.

26.13 Evitar llamadas externas reales

Una prueba unitaria no debería depender de internet, correo real, bases de datos externas ni servicios de terceros. Esos elementos pueden fallar por razones ajenas al código.

Si una prueba falla porque se cortó internet o porque un servicio externo cambió, probablemente no era una prueba unitaria aislada.

26.14 Archivo completo de pruebas

El archivo test_ventas.py puede quedar así:

from unittest.mock import Mock, patch

import pytest

from ventas import (
    Facturador,
    calcular_total_con_envio,
    confirmar_compra,
    convertir_a_dolares,
)


def test_convertir_a_dolares():
    with patch("ventas.obtener_cotizacion_dolar", return_value=1000):
        resultado = convertir_a_dolares(25000)

    assert resultado == 25


def test_convertir_a_dolares_consulta_cotizacion():
    with patch("ventas.obtener_cotizacion_dolar", return_value=500) as cotizacion_mock:
        resultado = convertir_a_dolares(1000)

    assert resultado == 2
    cotizacion_mock.assert_called_once_with()


def test_confirmar_compra_envia_email():
    cliente = {
        "nombre": "Ana",
        "email": "ana@example.com",
    }

    with patch("ventas.obtener_cotizacion_dolar", return_value=1000), \
         patch("ventas.enviar_email") as enviar_email_mock:
        resultado = confirmar_compra(cliente, 30000)

    assert resultado == {
        "cliente": "Ana",
        "total_usd": 30,
        "estado": "confirmada",
    }
    enviar_email_mock.assert_called_once_with(
        "ana@example.com",
        "Compra confirmada",
        "Total en dólares: 30.0",
    )


def test_confirmar_compra_si_cotizacion_falla_no_envia_email():
    cliente = {
        "nombre": "Ana",
        "email": "ana@example.com",
    }

    with patch("ventas.obtener_cotizacion_dolar", side_effect=RuntimeError), \
         patch("ventas.enviar_email") as enviar_email_mock:
        with pytest.raises(RuntimeError):
            confirmar_compra(cliente, 30000)

    enviar_email_mock.assert_not_called()


def test_calcular_total_con_envio():
    servicio_envios = Mock()
    servicio_envios.calcular_costo.return_value = 1500

    resultado = calcular_total_con_envio(10000, servicio_envios, "5000")

    assert resultado == 11500
    servicio_envios.calcular_costo.assert_called_once_with("5000")


def test_crear_factura_con_numero_mockeado():
    facturador = Facturador()

    with patch.object(Facturador, "generar_numero", return_value="F-001") as numero_mock:
        factura = facturador.crear_factura("Ana", 12000)

    assert factura == {
        "numero": "F-001",
        "cliente": "Ana",
        "total": 12000,
    }
    numero_mock.assert_called_once_with()


@patch("ventas.obtener_cotizacion_dolar", return_value=2000)
def test_convertir_a_dolares_con_decorador(cotizacion_mock):
    resultado = convertir_a_dolares(10000)

    assert resultado == 5
    cotizacion_mock.assert_called_once_with()

26.15 Ejecutar las pruebas

Desde la raíz del proyecto, ejecuta:

python -m pytest

La salida esperada será similar a:

collected 7 items

test_ventas.py .......                                          [100%]

7 passed in 0.05s

26.16 patch con with o decorador

Ambas formas son válidas:

  • Con with: el reemplazo queda cerca de la parte del test que lo usa.
  • Como decorador: puede reducir indentación, pero agrega parámetros al test.

Para aprender, suele ser más claro empezar con with patch(...).

26.17 Errores frecuentes

  • Patch en el lugar equivocado: reemplaza el nombre que usa el módulo bajo prueba.
  • Mockear demasiado: si todo está mockeado, quizá no estás probando comportamiento real.
  • No verificar efectos importantes: llamadas a correo, pagos o logs críticos pueden necesitar aserciones.
  • Dejar que una prueba llame a servicios reales: vuelve la suite lenta e inestable.
  • Confundir método con función: para métodos de clase, patch.object suele ser más claro.

26.18 Comandos usados en este tema

mkdir pytest-patch-demo
cd pytest-patch-demo
python -m pip install pytest
python -m pytest
python -m pytest -v
python -m pytest test_ventas.py::test_confirmar_compra_envia_email -v

26.19 Qué debes recordar de este tema

  • patch reemplaza temporalmente una dependencia.
  • Se aplica patch donde la dependencia se usa.
  • patch.object sirve para reemplazar métodos o atributos de objetos y clases.
  • Una dependencia inyectada puede reemplazarse directamente con Mock.
  • side_effect permite simular fallas externas.
  • Las pruebas unitarias no deberían llamar servicios externos reales.

26.20 Conclusión

En este tema aprendimos a mockear funciones, métodos y dependencias externas usando patch, patch.object y Mock.

En el próximo tema veremos monkeypatch de pytest, otra forma muy útil de reemplazar funciones, variables y atributos durante una prueba.