En este tema crearemos un stub manual para reemplazar una dependencia externa. El ejemplo será un servicio de cotización de monedas, algo típico en aplicaciones que calculan precios, pagos o conversiones.
La idea principal es que la prueba controle el valor que devuelve la dependencia. De esta manera podemos probar nuestra lógica sin conectarnos a una API real.
Supongamos que una tienda guarda sus precios en dólares, pero necesita mostrar el precio en pesos. Para eso usa un servicio externo que devuelve la cotización actual.
La función que queremos probar recibe el precio en dólares y un objeto capaz de obtener la cotización:
def convertir_precio_a_pesos(precio_usd, servicio_cotizacion):
cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
return precio_usd * cotizacion
La lógica es simple, pero depende de un valor externo. Si la prueba consulta una API real, el resultado puede cambiar todos los días.
Una implementación real del servicio podría hacer una llamada HTTP:
import requests
class ServicioCotizacionHttp:
def obtener_cotizacion(self, moneda_origen, moneda_destino):
respuesta = requests.get(
"https://api.example.com/cotizacion",
params={"origen": moneda_origen, "destino": moneda_destino},
timeout=5,
)
respuesta.raise_for_status()
datos = respuesta.json()
return datos["cotizacion"]
No queremos usar esta clase en una prueba unitaria de convertir_precio_a_pesos. La prueba quedaría atada a la red, a la disponibilidad del servicio y a una cotización cambiante.
Para controlar el escenario, podemos escribir un stub que tenga el mismo método que espera la función:
class ServicioCotizacionStub:
def obtener_cotizacion(self, moneda_origen, moneda_destino):
return 1000
Este objeto no sabe nada de HTTP. Solo devuelve una cotización fija. Es suficiente para probar que la función multiplica correctamente.
La prueba queda así:
from tienda.precios import convertir_precio_a_pesos
class ServicioCotizacionStub:
def obtener_cotizacion(self, moneda_origen, moneda_destino):
return 1000
def test_convertir_precio_a_pesos():
servicio = ServicioCotizacionStub()
resultado = convertir_precio_a_pesos(25, servicio)
assert resultado == 25000
La prueba es estable. Siempre usa una cotización de 1000, por lo que el resultado esperado es claro.
El stub debe cumplir el contrato que la función necesita. En este ejemplo, la función espera que el objeto recibido tenga un método llamado obtener_cotizacion que acepte dos argumentos y devuelva un número.
No hace falta que el stub implemente toda la clase real. Solo debe implementar lo que la unidad bajo prueba usa.
Si queremos probar diferentes cotizaciones, conviene que el stub reciba el valor en el constructor:
class ServicioCotizacionStub:
def __init__(self, cotizacion):
self.cotizacion = cotizacion
def obtener_cotizacion(self, moneda_origen, moneda_destino):
return self.cotizacion
Ahora cada prueba puede decidir qué cotización usar.
Podemos escribir dos pruebas con cotizaciones distintas:
def test_convertir_precio_a_pesos_con_cotizacion_1000():
servicio = ServicioCotizacionStub(cotizacion=1000)
resultado = convertir_precio_a_pesos(25, servicio)
assert resultado == 25000
def test_convertir_precio_a_pesos_con_cotizacion_1200():
servicio = ServicioCotizacionStub(cotizacion=1200)
resultado = convertir_precio_a_pesos(10, servicio)
assert resultado == 12000
La misma clase de stub sirve para armar diferentes escenarios sin duplicar código.
Podemos mejorar la función para validar el precio antes de consultar la cotización:
def convertir_precio_a_pesos(precio_usd, servicio_cotizacion):
if precio_usd <= 0:
raise ValueError("El precio debe ser mayor que cero")
cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
return precio_usd * cotizacion
Esta validación también se puede probar usando el mismo stub, aunque en este caso la dependencia no debería llegar a usarse si el precio es inválido.
La prueba del error puede escribirse con pytest.raises:
import pytest
def test_convertir_precio_a_pesos_rechaza_precio_invalido():
servicio = ServicioCotizacionStub(cotizacion=1000)
with pytest.raises(ValueError):
convertir_precio_a_pesos(0, servicio)
En esta prueba el stub existe solo porque la función lo requiere como argumento. Lo importante es verificar que el precio inválido produce un error.
Si queremos comprobar que la dependencia no fue consultada cuando el precio es inválido, podemos crear un stub que registre las llamadas:
class ServicioCotizacionSpyStub:
def __init__(self, cotizacion):
self.cotizacion = cotizacion
self.fue_consultado = False
def obtener_cotizacion(self, moneda_origen, moneda_destino):
self.fue_consultado = True
return self.cotizacion
Este objeto combina dos roles: devuelve una respuesta preparada y además registra si fue usado. En la práctica esto es común, aunque conviene mantenerlo simple.
Ahora podemos comprobar que el servicio no fue llamado:
def test_precio_invalido_no_consulta_cotizacion():
servicio = ServicioCotizacionSpyStub(cotizacion=1000)
with pytest.raises(ValueError):
convertir_precio_a_pesos(0, servicio)
assert servicio.fue_consultado is False
Esta verificación tiene sentido porque evita una llamada externa innecesaria cuando los datos de entrada ya son inválidos.
A veces queremos probar qué ocurre cuando la dependencia externa falla. Podemos hacer que el stub lance una excepción:
class ServicioCotizacionConErrorStub:
def obtener_cotizacion(self, moneda_origen, moneda_destino):
raise ConnectionError("No se pudo consultar la cotización")
Este stub permite simular un error de red sin depender de una red real.
Si queremos que la función traduzca ese error a un mensaje propio, podríamos escribir:
class CotizacionNoDisponibleError(Exception):
pass
def convertir_precio_a_pesos(precio_usd, servicio_cotizacion):
if precio_usd <= 0:
raise ValueError("El precio debe ser mayor que cero")
try:
cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
except ConnectionError as error:
raise CotizacionNoDisponibleError(
"La cotización no está disponible"
) from error
return precio_usd * cotizacion
Ahora la función oculta el detalle técnico de la dependencia y expone un error del dominio de la aplicación.
La prueba correspondiente sería:
from tienda.precios import CotizacionNoDisponibleError
def test_convertir_precio_a_pesos_si_no_hay_cotizacion_lanza_error_propio():
servicio = ServicioCotizacionConErrorStub()
with pytest.raises(CotizacionNoDisponibleError):
convertir_precio_a_pesos(25, servicio)
El stub nos permite llegar a un escenario difícil de reproducir de forma confiable con una dependencia real.
Un stub manual debería ser fácil de entender. Si empieza a tener muchas condiciones, muchos atributos y mucha lógica, tal vez el diseño de la prueba necesita cambiar.
Implementa pruebas para esta función:
def calcular_costo_envio(codigo_postal, servicio_envios):
tarifa = servicio_envios.obtener_tarifa(codigo_postal)
if tarifa is None:
return "No disponible"
return tarifa
Crea un stub que permita simular una tarifa disponible y otro escenario donde el servicio devuelve None.
Una solución con un stub configurable puede ser:
from tienda.envios import calcular_costo_envio
class ServicioEnviosStub:
def __init__(self, tarifa):
self.tarifa = tarifa
def obtener_tarifa(self, codigo_postal):
return self.tarifa
def test_calcular_costo_envio_con_tarifa_disponible():
servicio = ServicioEnviosStub(tarifa=3500)
resultado = calcular_costo_envio("5000", servicio)
assert resultado == 3500
def test_calcular_costo_envio_no_disponible():
servicio = ServicioEnviosStub(tarifa=None)
resultado = calcular_costo_envio("9999", servicio)
assert resultado == "No disponible"
Las pruebas no consultan un servicio real de envíos. Controlan directamente lo que devuelve la dependencia.
Un stub manual es una herramienta simple y muy útil para controlar dependencias externas. Permite fijar respuestas, simular errores y escribir pruebas rápidas sin depender de red, servicios remotos o datos cambiantes.
En el próximo tema veremos cómo la inyección de dependencias facilita todavía más este tipo de pruebas y evita que tengamos que modificar código global o aplicar parches innecesarios.