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.
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
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.
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.
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.
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.
patch donde la dependencia se usa, no donde nació.
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.
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.
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.
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.
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.
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.
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.
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()
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
Ambas formas son válidas:
with: el reemplazo queda cerca de la parte del test que lo usa.Para aprender, suele ser más claro empezar con with patch(...).
patch.object suele ser más claro.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
patch reemplaza temporalmente una dependencia.patch.object sirve para reemplazar métodos o atributos de objetos y clases.Mock.side_effect permite simular fallas externas.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.