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.
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.
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.
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.
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.
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.
Ahora agregamos una regla de negocio sin tocar archivos.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
json, requests o clientes de base de datos sin necesidad.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.
Construí con TDD un cálculo de envío separando dominio y entrada.
calcular_envio(peso) que no lea archivos ni consola.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.