21. TDD outside-in e inside-out: dos formas de iniciar el diseño

21.1 Objetivo del tema

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.

21.2 Qué significa outside-in

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.

Outside-in parte de la pregunta: ¿qué necesita lograr el usuario o el consumidor del sistema?

21.3 Qué significa inside-out

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.

Inside-out parte de la pregunta: ¿qué regla del dominio puedo modelar y verificar ahora?

21.4 Caso práctico

El requisito será el siguiente:

El sistema debe crear una orden de compra a partir de varios productos. La orden debe calcular su total y quedar guardada.

En ambos enfoques usaremos el mismo comportamiento final, pero el orden de diseño será diferente.

21.5 Outside-in: primera prueba desde el caso de uso

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.

21.6 Outside-in: código mínimo inicial

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.

21.7 Outside-in: descubrir colaboraciones

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.

21.8 Outside-in: refactor hacia el dominio

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.

21.9 Outside-in: caso de uso después del refactor

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.

21.10 Inside-out: empezar por una regla pequeña

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.

21.11 Inside-out: implementación del ítem

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.

21.12 Inside-out: probar el total de la orden

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.

21.13 Inside-out: implementar la orden

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.

21.14 Inside-out: conectar con el caso de uso

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.

21.15 Inside-out: caso de uso final

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.

21.16 Comparación práctica

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

21.17 Cuándo elegir outside-in

Outside-in suele funcionar bien cuando el flujo externo es claro y queremos descubrir qué colaboradores necesita el sistema.

  • Un endpoint de API ya está definido.
  • Un caso de uso tiene una entrada y una salida claras.
  • Queremos validar una interacción completa.
  • Necesitamos descubrir dependencias como repositorios, servicios o adaptadores.

21.18 Cuándo elegir inside-out

Inside-out suele ser útil cuando el dominio tiene reglas importantes que conviene entender antes de conectarlas con capas externas.

  • Hay cálculos o reglas complejas.
  • El modelo de dominio todavía está tomando forma.
  • Queremos avanzar con funciones o clases puras.
  • El borde externo todavía no está definido.

21.19 Errores frecuentes

  • Creer que solo uno de los dos enfoques es correcto.
  • Usar outside-in y terminar probando demasiados detalles internos con mocks.
  • Usar inside-out y construir objetos que ningún caso de uso necesita.
  • Mezclar muchos niveles de abstracción en una misma prueba.
  • No ejecutar la suite completa después de conectar las piezas.

21.20 Ejercicio práctico

Implementá el mismo requisito usando ambos enfoques.

  1. Primero hacelo con outside-in: empezá por una prueba de CrearOrden.
  2. Luego hacelo con inside-out: empezá por ItemOrden y Orden.
  3. Compará qué pruebas aparecieron en cada camino.
  4. Identificá qué enfoque te ayudó más a descubrir nombres y responsabilidades.
  5. Ejecutá python -m pytest después de cada paso importante.

21.21 Checklist del tema

  • Outside-in empieza por una necesidad visible del sistema.
  • Inside-out empieza por reglas internas del dominio.
  • Ambos enfoques pueden formar parte de una práctica sana de TDD.
  • La elección depende del problema, no de una regla fija.
  • Las pruebas deben mantenerse claras y enfocadas en su nivel de abstracción.

21.22 Conclusión

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.