En este tema compararemos dos formas de comenzar un diseño con TDD: outside-in e inside-out. Ambas usan pruebas primero, pero empiezan desde lugares distintos del sistema.
Vamos a trabajar con un caso práctico en Python: crear una orden de compra, calcular su total y guardarla mediante un repositorio.
Outside-in significa empezar desde el comportamiento visible para quien usa el sistema. Primero probamos una entrada externa: un caso de uso, un servicio de aplicación, una API o una operación que representa una intención completa.
Inside-out significa empezar desde una pieza interna del dominio. Primero diseñamos y probamos objetos o funciones pequeñas, y luego los conectamos con capas más externas.
El requisito será el siguiente:
En ambos enfoques usaremos el mismo comportamiento final, pero el orden de diseño será diferente.
En outside-in empezamos probando una operación completa: crear una orden. Todavía no existe la clase que lo hará, pero la prueba expresa cómo queremos usarla.
Archivo a crear: tests/test_crear_orden.py
from tienda.crear_orden import CrearOrden
class RepositorioOrdenesEnMemoria:
def __init__(self):
self.ordenes = []
def guardar(self, orden):
self.ordenes.append(orden)
def test_crear_orden_calcula_total_y_la_guarda():
repositorio = RepositorioOrdenesEnMemoria()
caso_de_uso = CrearOrden(repositorio)
orden = caso_de_uso.ejecutar([
{"nombre": "Libro", "precio": 30, "cantidad": 2},
{"nombre": "Lápiz", "precio": 5, "cantidad": 3},
])
assert orden.total == 75
assert repositorio.ordenes == [orden]
Esta prueba diseña desde el borde del sistema. Todavía no decidimos cómo será internamente la orden, pero sí definimos qué necesita lograr el caso de uso.
Creamos lo mínimo para que la prueba avance. Es normal que aparezcan clases simples y luego las mejoremos.
Archivo a crear: src/tienda/crear_orden.py
class Orden:
def __init__(self, total):
self.total = total
class CrearOrden:
def __init__(self, repositorio):
self.repositorio = repositorio
def ejecutar(self, productos):
total = 0
for producto in productos:
total += producto["precio"] * producto["cantidad"]
orden = Orden(total)
self.repositorio.guardar(orden)
return orden
Ejecutamos python -m pytest. Si la prueba pasa, tenemos una primera versión
funcional del flujo completo.
La prueba nos mostró una colaboración: CrearOrden necesita guardar la orden en
un repositorio. En outside-in, estas colaboraciones suelen aparecer temprano.
El repositorio usado en la prueba es una implementación en memoria. No necesitamos base de datos para diseñar el comportamiento del caso de uso.
Con la prueba en verde, podemos extraer conceptos del dominio. Por ejemplo, una orden puede estar formada por ítems.
Archivo a modificar: src/tienda/crear_orden.py
class ItemOrden:
def __init__(self, nombre, precio, cantidad):
self.nombre = nombre
self.precio = precio
self.cantidad = cantidad
@property
def subtotal(self):
return self.precio * self.cantidad
class Orden:
def __init__(self, items):
self.items = items
@property
def total(self):
return sum(item.subtotal for item in self.items)
Este refactor no cambia el resultado externo. La orden sigue teniendo un total y sigue guardándose.
El caso de uso queda encargado de transformar los datos de entrada en objetos del dominio.
Archivo a modificar: src/tienda/crear_orden.py
class CrearOrden:
def __init__(self, repositorio):
self.repositorio = repositorio
def ejecutar(self, productos):
items = [
ItemOrden(
producto["nombre"],
producto["precio"],
producto["cantidad"]
)
for producto in productos
]
orden = Orden(items)
self.repositorio.guardar(orden)
return orden
Después del refactor, volvemos a ejecutar la suite. La prueba inicial nos protege el flujo completo.
En inside-out podríamos comenzar por el cálculo de subtotal de un ítem. La primera prueba se enfoca en una regla interna del dominio.
Archivo a crear: tests/test_orden.py
from tienda.orden import ItemOrden
def test_item_calcula_su_subtotal():
item = ItemOrden(nombre="Libro", precio=30, cantidad=2)
assert item.subtotal == 60
Esta prueba no habla todavía del caso de uso ni del repositorio. Empieza por una pieza pequeña y concreta.
Implementamos lo mínimo para pasar la prueba.
Archivo a crear: src/tienda/orden.py
class ItemOrden:
def __init__(self, nombre, precio, cantidad):
self.nombre = nombre
self.precio = precio
self.cantidad = cantidad
@property
def subtotal(self):
return self.precio * self.cantidad
Ejecutamos python -m pytest y pasamos al siguiente comportamiento interno.
Luego agregamos la orden como composición de ítems.
Archivo a modificar: tests/test_orden.py
from tienda.orden import ItemOrden, Orden
def test_orden_calcula_el_total_de_sus_items():
orden = Orden([
ItemOrden(nombre="Libro", precio=30, cantidad=2),
ItemOrden(nombre="Lápiz", precio=5, cantidad=3),
])
assert orden.total == 75
El diseño crece desde los objetos del dominio hacia el caso de uso.
Agregamos la clase Orden.
Archivo a modificar: src/tienda/orden.py
class Orden:
def __init__(self, items):
self.items = items
@property
def total(self):
return sum(item.subtotal for item in self.items)
En este punto todavía no hay caso de uso. Tenemos reglas internas bien probadas.
Recién ahora escribimos una prueba para crear y guardar la orden.
Archivo a crear: tests/test_crear_orden.py
from tienda.crear_orden import CrearOrden
class RepositorioOrdenesEnMemoria:
def __init__(self):
self.ordenes = []
def guardar(self, orden):
self.ordenes.append(orden)
def test_crear_orden_guarda_la_orden():
repositorio = RepositorioOrdenesEnMemoria()
caso_de_uso = CrearOrden(repositorio)
orden = caso_de_uso.ejecutar([
{"nombre": "Libro", "precio": 30, "cantidad": 2}
])
assert orden.total == 60
assert repositorio.ordenes == [orden]
La diferencia es que ahora el dominio ya existe antes de llegar al borde del sistema.
El caso de uso reutiliza las clases ya diseñadas.
Archivo a crear: src/tienda/crear_orden.py
from tienda.orden import ItemOrden, Orden
class CrearOrden:
def __init__(self, repositorio):
self.repositorio = repositorio
def ejecutar(self, productos):
items = [
ItemOrden(
nombre=producto["nombre"],
precio=producto["precio"],
cantidad=producto["cantidad"]
)
for producto in productos
]
orden = Orden(items)
self.repositorio.guardar(orden)
return orden
Ejecutamos toda la suite. Las pruebas pequeñas del dominio y la prueba del caso de uso deben quedar en verde.
| Enfoque | Empieza por | Ventaja | Riesgo |
|---|---|---|---|
| Outside-in | Caso de uso o borde externo | Diseña desde una necesidad visible | Puede requerir dobles de prueba antes de tener dominio claro |
| Inside-out | Reglas internas del dominio | Permite modelar piezas pequeñas con precisión | Puede construir partes que luego no encajan con el flujo real |
Outside-in suele funcionar bien cuando el flujo externo es claro y queremos descubrir qué colaboradores necesita el sistema.
Inside-out suele ser útil cuando el dominio tiene reglas importantes que conviene entender antes de conectarlas con capas externas.
Implementá el mismo requisito usando ambos enfoques.
CrearOrden.ItemOrden y Orden.python -m pytest después de cada paso importante.Outside-in e inside-out son dos caminos válidos para iniciar el diseño con TDD. El primero ayuda a pensar desde la necesidad externa; el segundo ayuda a construir reglas internas con precisión. Un desarrollador práctico sabe alternarlos según el contexto.
En el próximo tema construiremos una calculadora de descuentos con TDD paso a paso.