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.
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.
Cuando el código crea directamente sus dependencias, la prueba queda atada a detalles internos:
Eso no significa que patch sea malo. Significa que muchas veces podemos evitarlo con un diseño más simple.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Estas señales suelen indicar que conviene aplicar inyección de dependencias:
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.
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.
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.
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.
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.
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.