26. Diseño de límites entre lógica de dominio y entrada o salida

26.1 Objetivo del tema

En este tema veremos cómo separar la lógica de dominio de la entrada y salida. En TDD esta separación es importante porque permite probar las reglas principales con rapidez, claridad y sin depender de archivos, bases de datos, red o consola.

Construiremos un ejemplo práctico: calcular el total de un pedido desde datos que podrían venir de un archivo JSON.

26.2 Qué es lógica de dominio

La lógica de dominio representa las reglas importantes del problema. Por ejemplo: calcular subtotales, aplicar descuentos, validar cantidades o decidir si un pedido es válido.

La lógica de dominio debería poder probarse sin leer archivos, sin escribir en pantalla y sin conectarse a servicios externos.

26.3 Qué es entrada o salida

Entrada o salida es todo lo que conecta el programa con el mundo exterior: archivos, base de datos, consola, variables de entorno, APIs, colas de mensajes o reloj del sistema.

Esas partes también se prueban, pero no deberían mezclarse con las reglas centrales si podemos evitarlo.

26.4 Un diseño difícil de probar

Este ejemplo mezcla lectura de archivo, parseo JSON y cálculo de negocio en una sola función.

Ejemplo a evitar:

import json


def calcular_total_desde_archivo(ruta):
    with open(ruta, "r", encoding="utf-8") as archivo:
        datos = json.load(archivo)

    total = 0

    for item in datos["items"]:
        total += item["precio"] * item["cantidad"]

    return total

Para probar el cálculo hay que crear un archivo. Eso hace que una regla simple dependa de entrada y salida.

26.5 Primera prueba: dominio puro

Empezamos por la regla de dominio: calcular el total de un pedido a partir de sus ítems.

Archivo a crear: tests/test_pedidos.py

from pedidos import calcular_total


def test_calcula_total_de_un_pedido():
    items = [
        {"producto": "Libro", "precio": 30, "cantidad": 2},
        {"producto": "Lápiz", "precio": 5, "cantidad": 3},
    ]

    total = calcular_total(items)

    assert total == 75

Esta prueba no necesita archivos. Solo usa datos de entrada claros y verifica una regla.

26.6 Implementación mínima del dominio

Creamos la función de dominio sin entrada o salida.

Archivo a crear: src/pedidos.py

def calcular_total(items):
    total = 0

    for item in items:
        total += item["precio"] * item["cantidad"]

    return total

Ejecutamos python -m pytest. Si pasa, tenemos una regla importante protegida.

26.7 Agregar una regla de descuento

Ahora agregamos una regla de negocio sin tocar archivos.

Si el total del pedido es mayor o igual a 100, se aplica 10% de descuento.

Archivo a modificar: tests/test_pedidos.py

def test_aplica_descuento_para_pedidos_grandes():
    items = [
        {"producto": "Silla", "precio": 60, "cantidad": 2},
    ]

    total = calcular_total(items)

    assert total == 108

El subtotal es 120 y el total final con 10% de descuento debe ser 108.

26.8 Implementar la regla sin mezclar entrada

La función sigue recibiendo datos ya cargados. No sabe de dónde vinieron.

Archivo a modificar: src/pedidos.py

def calcular_total(items):
    subtotal = 0

    for item in items:
        subtotal += item["precio"] * item["cantidad"]

    if subtotal >= 100:
        return subtotal * 0.90

    return subtotal

La prueba guía el cambio de negocio. La entrada y salida permanecen fuera.

26.9 Refactor: separar subtotal y descuento

Con la suite en verde, podemos mejorar nombres y estructura.

Archivo a modificar: src/pedidos.py

def calcular_total(items):
    subtotal = calcular_subtotal(items)

    return aplicar_descuento_por_volumen(subtotal)


def calcular_subtotal(items):
    return sum(
        item["precio"] * item["cantidad"]
        for item in items
    )


def aplicar_descuento_por_volumen(subtotal):
    if subtotal >= 100:
        return subtotal * 0.90

    return subtotal

El refactor no cambia el comportamiento. Después del cambio, ejecutamos la suite completa.

26.10 Ahora sí: adaptar entrada JSON

Una vez que la regla está aislada, podemos crear una función de entrada que lea JSON y llame al dominio.

Archivo a crear: src/adaptadores.py

import json

from pedidos import calcular_total


def calcular_total_desde_json(texto_json):
    datos = json.loads(texto_json)

    return calcular_total(datos["items"])

Esta función adapta un texto JSON a datos del dominio. No contiene reglas de descuento.

26.11 Probar el adaptador sin tocar archivos

Podemos probar el parseo JSON pasando un texto fijo.

Archivo a crear: tests/test_adaptadores.py

from adaptadores import calcular_total_desde_json


def test_calcula_total_desde_texto_json():
    texto_json = """
    {
        "items": [
            {"producto": "Libro", "precio": 30, "cantidad": 2},
            {"producto": "Lápiz", "precio": 5, "cantidad": 3}
        ]
    }
    """

    total = calcular_total_desde_json(texto_json)

    assert total == 75

Esta prueba cubre el límite de entrada JSON, pero sigue sin depender del sistema de archivos.

26.12 Dejar el archivo en el borde

Si necesitamos leer desde un archivo, esa responsabilidad puede quedar en una función pequeña.

Archivo a modificar: src/adaptadores.py

def leer_texto(ruta):
    with open(ruta, "r", encoding="utf-8") as archivo:
        return archivo.read()

Esta función no calcula nada. Solo lee texto. Esa separación hace que sea menos riesgoso cambiar una parte sin romper la otra.

26.13 Caso de uso que coordina

Podemos tener una función de aplicación que coordine lectura y cálculo.

Archivo a modificar: src/adaptadores.py

def calcular_total_desde_archivo(ruta):
    texto_json = leer_texto(ruta)

    return calcular_total_desde_json(texto_json)

La coordinación está separada de la regla de negocio.

26.14 Probar coordinación con una función inyectada

Para evitar crear archivos en una prueba de coordinación, podemos inyectar la función que lee el texto.

Archivo a modificar: src/adaptadores.py

def calcular_total_desde_archivo(ruta, leer=leer_texto):
    texto_json = leer(ruta)

    return calcular_total_desde_json(texto_json)

En producción se usa leer_texto. En pruebas podemos pasar una función controlada.

26.15 Prueba de coordinación sin archivo real

La prueba controla la entrada sin depender del disco.

Archivo a modificar: tests/test_adaptadores.py

def test_calcula_total_desde_archivo_usando_lector_inyectado():
    def leer_falso(ruta):
        return """
        {
            "items": [
                {"producto": "Libro", "precio": 30, "cantidad": 2}
            ]
        }
        """

    total = calcular_total_desde_archivo("pedido.json", leer=leer_falso)

    assert total == 60

La ruta existe como dato del ejemplo, pero la prueba no necesita crear el archivo.

26.16 Cuándo probar entrada o salida real

Separar límites no significa ignorar la entrada y salida real. Significa no mezclarla con todas las pruebas del dominio.

Puede haber una prueba pequeña para confirmar que leer_texto lee un archivo, pero no hace falta usar archivos para probar cada regla de descuento.

26.17 Prueba puntual con archivo temporal

Si queremos probar lectura real, tmp_path de pytest permite crear archivos temporales.

Archivo a modificar: tests/test_adaptadores.py

from adaptadores import leer_texto


def test_lee_texto_desde_archivo_temporal(tmp_path):
    ruta = tmp_path / "pedido.json"
    ruta.write_text('{"items": []}', encoding="utf-8")

    texto = leer_texto(ruta)

    assert texto == '{"items": []}'

Esta prueba verifica el adaptador de archivo, no las reglas de negocio.

26.18 Señales de límites mal ubicados

  • Una prueba de una regla simple necesita crear archivos o configurar red.
  • El dominio importa módulos como json, requests o clientes de base de datos sin necesidad.
  • Un cambio en el formato de entrada rompe pruebas de reglas de negocio.
  • El código mezcla cálculo, parseo, validación externa y escritura en una misma función.
  • Las pruebas son lentas aunque las reglas sean simples.

26.19 Estructura sugerida

Una estructura simple puede separar dominio, adaptadores y pruebas.

src/
  pedidos.py
  adaptadores.py
tests/
  test_pedidos.py
  test_adaptadores.py

No hace falta crear muchas capas desde el inicio. La separación debe ayudar a entender y probar mejor el sistema.

26.20 Ejercicio práctico

Construí con TDD un cálculo de envío separando dominio y entrada.

  1. Creá una función calcular_envio(peso) que no lea archivos ni consola.
  2. Agregá una regla: hasta 1 kg cuesta 500.
  3. Agregá una regla: más de 1 kg cuesta 500 más 200 por kg adicional.
  4. Creá un adaptador que reciba JSON con el peso y devuelva el costo.
  5. Probá el dominio sin JSON y el adaptador con un texto JSON fijo.

26.21 Checklist del tema

  • Las reglas de dominio se prueban sin entrada o salida real.
  • Los adaptadores convierten formatos externos en datos del dominio.
  • La lectura de archivos queda en el borde del sistema.
  • La coordinación puede probarse con dependencias inyectadas.
  • Las pruebas de entrada o salida real son pocas, específicas y separadas.

26.22 Conclusión

Diseñar límites claros entre dominio y entrada o salida hace que TDD sea más efectivo. Las reglas importantes quedan en funciones fáciles de probar, mientras que los adaptadores se ocupan de traducir datos externos sin contaminar el modelo.

En el próximo tema veremos cómo usar dobles de prueba de forma moderada para dependencias externas.