5. Inyección de dependencias para evitar pruebas frágiles

5.1 Objetivo del tema

La inyección de dependencias es una técnica de diseño que facilita mucho el uso de stubs y mocks. Consiste en recibir desde afuera los objetos o funciones que una unidad necesita para trabajar, en lugar de crearlos directamente dentro de esa unidad.

En este tema veremos cómo pequeñas decisiones de diseño pueden hacer que una prueba sea simple, estable y clara.

Objetivo práctico: refactorizar código Python para que sus dependencias puedan reemplazarse fácilmente durante las pruebas.

5.2 Código difícil de probar

Observa esta función que calcula el precio final de un producto. La función crea internamente el cliente HTTP que consulta la cotización:

from tienda.cotizaciones import ServicioCotizacionHttp


def calcular_precio_final(precio_usd):
    servicio = ServicioCotizacionHttp()
    cotizacion = servicio.obtener_cotizacion("USD", "ARS")
    return precio_usd * cotizacion

El problema es que la función decide por sí misma qué dependencia usar. Si queremos probarla sin red, necesitamos aplicar técnicas más invasivas, como parchear el nombre ServicioCotizacionHttp.

5.3 Por qué esta prueba sería frágil

Cuando el código crea directamente sus dependencias, la prueba queda atada a detalles internos:

  • Debe saber qué clase exacta se instancia.
  • Debe saber en qué módulo fue importada.
  • Debe interceptar la creación del objeto.
  • Puede romperse si cambiamos la implementación aunque el comportamiento siga igual.

Eso no significa que patch sea malo. Significa que muchas veces podemos evitarlo con un diseño más simple.

5.4 Inyectar la dependencia por parámetro

La primera mejora consiste en recibir el servicio desde afuera:

def calcular_precio_final(precio_usd, servicio_cotizacion):
    cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
    return precio_usd * cotizacion

Ahora la función no sabe si recibe un cliente HTTP real, un stub manual o un mock. Solo necesita un objeto que cumpla el contrato esperado.

5.5 Prueba con stub después de inyectar

La prueba se vuelve directa:

from tienda.precios import calcular_precio_final


class ServicioCotizacionStub:
    def obtener_cotizacion(self, moneda_origen, moneda_destino):
        return 1000


def test_calcular_precio_final():
    servicio = ServicioCotizacionStub()

    resultado = calcular_precio_final(30, servicio)

    assert resultado == 30000

No hay red, no hay configuración global y no hay que interceptar imports. La dependencia se reemplaza de forma explícita.

5.6 Inyección no significa complicar el código

En Python, la inyección de dependencias suele ser tan simple como pasar un objeto, una función o una configuración como argumento. No necesitamos un framework especial para aplicarla.

Inyectar una dependencia significa hacer visible desde afuera qué necesita una unidad para trabajar.

5.7 Inyectar una función

No siempre necesitamos inyectar objetos. Si la dependencia es una operación simple, podemos inyectar una función:

def generar_codigo_descuento(usuario_id, generar_token):
    token = generar_token()
    return f"{usuario_id}-{token}"

En producción, generar_token podría usar una función aleatoria. En la prueba, usamos una función controlada:

def token_fijo():
    return "ABC123"


def test_generar_codigo_descuento():
    codigo = generar_codigo_descuento("USR-1", token_fijo)

    assert codigo == "USR-1-ABC123"

La prueba no depende de aleatoriedad. El valor está controlado.

5.8 Inyectar dependencias por constructor

Cuando trabajamos con clases, suele ser cómodo inyectar dependencias en el constructor:

class ServicioPedidos:
    def __init__(self, repositorio, notificador):
        self.repositorio = repositorio
        self.notificador = notificador

    def crear_pedido(self, usuario_id, items):
        pedido = {
            "usuario_id": usuario_id,
            "items": items,
            "estado": "creado",
        }
        pedido_id = self.repositorio.guardar(pedido)
        self.notificador.enviar_confirmacion(usuario_id, pedido_id)
        return pedido_id

La clase no crea su repositorio ni su notificador. Los recibe. Esto permite usar implementaciones reales en producción y dobles de prueba en testing.

5.9 Prueba de una clase con dependencias inyectadas

Podemos probar ServicioPedidos con un fake y un spy:

class RepositorioPedidosFake:
    def __init__(self):
        self.pedidos = {}
        self.proximo_id = 1

    def guardar(self, pedido):
        pedido_id = self.proximo_id
        self.proximo_id += 1
        self.pedidos[pedido_id] = pedido
        return pedido_id


class NotificadorSpy:
    def __init__(self):
        self.confirmaciones = []

    def enviar_confirmacion(self, usuario_id, pedido_id):
        self.confirmaciones.append((usuario_id, pedido_id))


def test_crear_pedido_guarda_y_notifica():
    repositorio = RepositorioPedidosFake()
    notificador = NotificadorSpy()
    servicio = ServicioPedidos(repositorio, notificador)

    pedido_id = servicio.crear_pedido("USR-1", [{"producto": "Libro"}])

    assert repositorio.pedidos[pedido_id]["estado"] == "creado"
    assert notificador.confirmaciones == [("USR-1", pedido_id)]

El test arma el escenario con dobles explícitos y comprueba el comportamiento que importa.

5.10 Dependencias por defecto

A veces queremos que el código de producción sea cómodo de usar, pero que las pruebas puedan reemplazar dependencias. Una opción es permitir una dependencia opcional:

from tienda.cotizaciones import ServicioCotizacionHttp


def calcular_precio_final(precio_usd, servicio_cotizacion=None):
    if servicio_cotizacion is None:
        servicio_cotizacion = ServicioCotizacionHttp()

    cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
    return precio_usd * cotizacion

En producción se puede llamar con un solo argumento. En pruebas se pasa un stub.

5.11 Cuidado con valores por defecto mutables o instanciados

No conviene escribir algo como esto:

def calcular_precio_final(
    precio_usd,
    servicio_cotizacion=ServicioCotizacionHttp()
):
    cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
    return precio_usd * cotizacion

El objeto se crea cuando Python define la función, no cada vez que se llama. Además, puede dificultar el reemplazo en pruebas. Es más claro usar None como valor por defecto y construir la dependencia dentro del cuerpo solo si hace falta.

5.12 Separar creación y uso

Una regla práctica para mejorar la testeabilidad es separar el lugar donde se crean las dependencias del lugar donde se usan.

Por ejemplo, una función principal puede armar los objetos reales:

def crear_servicio_pedidos():
    repositorio = RepositorioPedidosPostgres()
    notificador = NotificadorEmail()
    return ServicioPedidos(repositorio, notificador)

Mientras tanto, la lógica de ServicioPedidos sigue recibiendo sus dependencias por constructor. Las pruebas no necesitan usar la función de armado real.

5.13 Señales de código difícil de testear

Estas señales suelen indicar que conviene aplicar inyección de dependencias:

  • La función crea clientes HTTP, conexiones o repositorios dentro de su cuerpo.
  • La prueba necesita modificar variables globales para funcionar.
  • La prueba falla si no hay internet, base de datos o configuración externa.
  • Hay que usar muchos parches para ejecutar una prueba simple.
  • El código mezcla lógica de negocio con detalles de infraestructura.

5.14 Refactorización paso a paso

Podemos transformar este código:

def enviar_resumen(usuario_id):
    repositorio = RepositorioUsuarios()
    email = ServicioEmail()

    usuario = repositorio.buscar_por_id(usuario_id)
    email.enviar(usuario["email"], "Resumen semanal")

    return True

En una versión más fácil de probar:

def enviar_resumen(usuario_id, repositorio, email):
    usuario = repositorio.buscar_por_id(usuario_id)
    email.enviar(usuario["email"], "Resumen semanal")

    return True

El comportamiento no cambió, pero ahora la prueba puede pasar un stub para el repositorio y un spy para el email.

5.15 Prueba después de refactorizar

La prueba queda así:

class RepositorioUsuariosStub:
    def buscar_por_id(self, usuario_id):
        return {"id": usuario_id, "email": "ana@example.com"}


class EmailSpy:
    def __init__(self):
        self.mensajes = []

    def enviar(self, destino, asunto):
        self.mensajes.append({"destino": destino, "asunto": asunto})


def test_enviar_resumen_envia_email_al_usuario():
    repositorio = RepositorioUsuariosStub()
    email = EmailSpy()

    resultado = enviar_resumen("USR-1", repositorio, email)

    assert resultado is True
    assert email.mensajes == [
        {"destino": "ana@example.com", "asunto": "Resumen semanal"}
    ]

La prueba no necesita una base de datos ni un servidor de correo. Las dependencias fueron inyectadas y reemplazadas por dobles simples.

5.16 Inyección de dependencias y diseño

La inyección de dependencias no solo mejora las pruebas. También hace que el diseño sea más explícito. Una clase o función muestra qué necesita para trabajar, y eso reduce acoplamientos ocultos.

Cuando una unidad recibe sus colaboradores desde afuera, resulta más fácil reutilizarla, probarla y cambiar su infraestructura sin tocar la lógica de negocio.

El mocking suele ser más simple cuando el diseño ya permite reemplazar dependencias.

5.17 Ejercicio práctico

Refactoriza esta función para que sea más fácil de probar:

def calcular_descuento_usuario(usuario_id, total):
    repositorio = RepositorioUsuarios()
    usuario = repositorio.buscar_por_id(usuario_id)

    if usuario["categoria"] == "vip":
        return total * 0.20

    return total * 0.05

Luego escribe pruebas para un usuario VIP y un usuario común usando un stub.

5.18 Solución posible del ejercicio

Primero inyectamos el repositorio:

def calcular_descuento_usuario(usuario_id, total, repositorio):
    usuario = repositorio.buscar_por_id(usuario_id)

    if usuario["categoria"] == "vip":
        return total * 0.20

    return total * 0.05

Luego probamos con un stub configurable:

class RepositorioUsuariosStub:
    def __init__(self, usuario):
        self.usuario = usuario

    def buscar_por_id(self, usuario_id):
        return self.usuario


def test_descuento_usuario_vip():
    repositorio = RepositorioUsuariosStub({"categoria": "vip"})

    descuento = calcular_descuento_usuario("USR-1", 1000, repositorio)

    assert descuento == 200


def test_descuento_usuario_comun():
    repositorio = RepositorioUsuariosStub({"categoria": "comun"})

    descuento = calcular_descuento_usuario("USR-2", 1000, repositorio)

    assert descuento == 50

La lógica se prueba sin crear un repositorio real. El escenario queda claro en cada prueba.

5.19 Conclusión

La inyección de dependencias permite reemplazar componentes reales por stubs, fakes, spies o mocks sin forzar la prueba. En Python puede aplicarse de manera sencilla con parámetros, constructores o funciones inyectadas.

En el próximo tema usaremos esta idea para reemplazar consultas a una base de datos por un stub simple.