4. Crear el primer stub manual para controlar una dependencia externa

4.1 Objetivo del tema

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.

Objetivo práctico: escribir un stub manual que simule una respuesta externa y permita probar código Python de forma rápida y determinística.

4.2 Caso de ejemplo

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.

4.3 Problema de probar con la dependencia real

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.

4.4 Crear un stub con respuesta fija

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.

4.5 Primera prueba con el stub

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.

4.6 Qué contrato debe cumplir el stub

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.

Un stub debe ser lo más pequeño posible, pero no tan incompleto como para ocultar errores importantes de integración.

4.7 Stub configurable

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.

4.8 Pruebas con varios valores

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.

4.9 Validar datos antes de usar la dependencia

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.

4.10 Probar el caso 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.

4.11 Stub que registra si fue usado

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.

4.12 Verificar que no se consulta la dependencia

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.

4.13 Simular errores de la dependencia

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.

4.14 Manejar el error en el código de aplicación

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.

4.15 Probar el error simulado

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.

4.16 Stubs pequeños y legibles

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.

  • El nombre del stub debe indicar qué representa.
  • El constructor puede recibir los datos importantes del escenario.
  • El stub debe implementar solo los métodos necesarios para la prueba.
  • La lógica compleja debe permanecer en el código probado, no dentro del stub.

4.17 Ejercicio práctico

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.

4.18 Solución posible del ejercicio

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.

4.19 Conclusión

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.