En este tema veremos cómo un modelo de dominio puede crecer paso a paso usando TDD. La idea central es no intentar diseñar todo el sistema desde el principio, sino permitir que las pruebas guíen la aparición de nuevos datos, métodos y reglas.
Vamos a continuar con un ejemplo de cuenta bancaria, pero ahora agregaremos conceptos propios del dominio: movimientos, transferencias e invariantes que deben mantenerse aunque el modelo se vuelva más rico.
Un modelo de dominio representa conceptos importantes del problema que estamos resolviendo. No es solamente una colección de clases: es una forma de expresar reglas de negocio con nombres claros y comportamiento verificable.
En TDD, ese lenguaje no aparece completo de una vez. Lo descubrimos a medida que escribimos pruebas que expresan comportamientos concretos.
Supongamos que venimos de una clase simple que ya permite depositar y retirar dinero. Todavía no registra el historial de operaciones.
Archivo existente: src/banco/cuenta.py
class CuentaBancaria:
def __init__(self):
self.saldo = 0
def depositar(self, importe):
self._validar_importe_positivo(importe)
self.saldo += importe
def retirar(self, importe):
self._validar_importe_positivo(importe)
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
def _validar_importe_positivo(self, importe):
if importe <= 0:
raise ValueError("El importe debe ser positivo")
Este código resuelve reglas básicas, pero el dominio todavía no expresa algo importante: qué operaciones ocurrieron sobre la cuenta.
Aparece un nuevo requerimiento: cada depósito debe quedar registrado como un movimiento. En lugar de agregar código directamente, primero escribimos una prueba que describa el comportamiento esperado.
Escribimos una prueba pequeña. Todavía no decidimos una arquitectura completa para los movimientos. Solo expresamos el comportamiento que necesitamos ahora.
Archivo a crear o modificar: tests/test_cuenta.py
from banco.cuenta import CuentaBancaria
def test_deposito_registra_un_movimiento():
cuenta = CuentaBancaria()
cuenta.depositar(100)
assert cuenta.movimientos == [
{"tipo": "deposito", "importe": 100}
]
Esta prueba probablemente falle porque la cuenta todavía no tiene el atributo
movimientos. Ese fallo es útil: nos muestra el siguiente cambio mínimo.
Agregamos una lista de movimientos y registramos el depósito. No agregamos todavía transferencias, filtros, fechas ni clases adicionales.
Archivo a modificar: src/banco/cuenta.py
class CuentaBancaria:
def __init__(self):
self.saldo = 0
self.movimientos = []
def depositar(self, importe):
self._validar_importe_positivo(importe)
self.saldo += importe
self.movimientos.append({
"tipo": "deposito",
"importe": importe
})
def retirar(self, importe):
self._validar_importe_positivo(importe)
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
def _validar_importe_positivo(self, importe):
if importe <= 0:
raise ValueError("El importe debe ser positivo")
Ejecutamos python -m pytest. Si la prueba pasa, estamos en verde y podemos
continuar con otro comportamiento.
Ahora agregamos una prueba para el retiro. El modelo empieza a mostrar una idea más clara: una cuenta no solo cambia su saldo, también conserva el historial de sus operaciones.
Archivo a modificar: tests/test_cuenta.py
def test_retiro_registra_un_movimiento():
cuenta = CuentaBancaria()
cuenta.depositar(100)
cuenta.retirar(40)
assert cuenta.movimientos[-1] == {
"tipo": "retiro",
"importe": 40
}
Esta prueba revisa el último movimiento porque el depósito inicial también deja un registro.
Modificamos el método retirar para registrar el movimiento después de validar
que la operación es posible.
Archivo a modificar: src/banco/cuenta.py
def retirar(self, importe):
self._validar_importe_positivo(importe)
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
self.movimientos.append({
"tipo": "retiro",
"importe": importe
})
El orden importa: si el retiro no puede realizarse, no debe quedar registrado como si hubiera ocurrido.
El uso de diccionarios funciona, pero empieza a aparecer una señal: repetimos las claves
tipo e importe y dependemos de cadenas escritas manualmente.
Antes de cambiar el diseño, esperamos a estar en verde.
Podemos representar cada movimiento con una clase pequeña. Como solo necesitamos guardar
datos, una dataclass es suficiente.
Archivo a modificar: src/banco/cuenta.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Movimiento:
tipo: str
importe: float
class CuentaBancaria:
def __init__(self):
self.saldo = 0
self.movimientos = []
def depositar(self, importe):
self._validar_importe_positivo(importe)
self.saldo += importe
self.movimientos.append(Movimiento("deposito", importe))
def retirar(self, importe):
self._validar_importe_positivo(importe)
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
self.movimientos.append(Movimiento("retiro", importe))
def _validar_importe_positivo(self, importe):
if importe <= 0:
raise ValueError("El importe debe ser positivo")
El comportamiento no cambia, pero el modelo ahora tiene un concepto explícito:
Movimiento.
Como el movimiento pasó a ser parte del lenguaje público del modelo, las pruebas pueden expresar el resultado usando esa clase.
Archivo a modificar: tests/test_cuenta.py
from banco.cuenta import CuentaBancaria, Movimiento
def test_deposito_registra_un_movimiento():
cuenta = CuentaBancaria()
cuenta.depositar(100)
assert cuenta.movimientos == [
Movimiento("deposito", 100)
]
def test_retiro_registra_un_movimiento():
cuenta = CuentaBancaria()
cuenta.depositar(100)
cuenta.retirar(40)
assert cuenta.movimientos[-1] == Movimiento("retiro", 40)
Este cambio es aceptable porque la clase Movimiento representa un concepto
del dominio, no un detalle accidental de implementación.
Ahora el dominio crece otra vez. Queremos transferir dinero desde una cuenta hacia otra. Empezamos con el caso más simple: una transferencia válida mueve saldo entre dos cuentas.
Archivo a modificar: tests/test_cuenta.py
def test_transferencia_mueve_saldo_entre_cuentas():
origen = CuentaBancaria()
destino = CuentaBancaria()
origen.depositar(100)
origen.transferir_a(destino, 40)
assert origen.saldo == 60
assert destino.saldo == 40
La prueba nombra una acción del dominio: transferir_a. Ese nombre ayuda a que
el código se lea desde la perspectiva del problema.
Para pasar la prueba no necesitamos duplicar toda la lógica. La cuenta ya sabe retirar y depositar.
Archivo a modificar: src/banco/cuenta.py
def transferir_a(self, destino, importe):
self.retirar(importe)
destino.depositar(importe)
Esta implementación es simple y expresa bien la operación, pero todavía falta analizar qué ocurre con los movimientos registrados.
Si transferimos dinero, el historial debería indicar que no fue un retiro común ni un depósito común. Es una regla nueva, por lo tanto la escribimos primero como prueba.
Archivo a modificar: tests/test_cuenta.py
def test_transferencia_registra_movimientos_en_ambas_cuentas():
origen = CuentaBancaria()
destino = CuentaBancaria()
origen.depositar(100)
origen.transferir_a(destino, 40)
assert origen.movimientos[-1] == Movimiento("transferencia_enviada", 40)
assert destino.movimientos[-1] == Movimiento("transferencia_recibida", 40)
Esta prueba nos obliga a distinguir una transferencia de un retiro o depósito normal.
Para registrar movimientos específicos de transferencia, no conviene llamar directamente
a retirar y depositar, porque esos métodos registran otros tipos
de movimiento. Podemos extraer operaciones internas más pequeñas.
Archivo a modificar: src/banco/cuenta.py
def transferir_a(self, destino, importe):
self._validar_importe_positivo(importe)
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
destino.saldo += importe
self.movimientos.append(Movimiento("transferencia_enviada", importe))
destino.movimientos.append(Movimiento("transferencia_recibida", importe))
Luego ejecutamos toda la suite. No basta con que pase la nueva prueba: los depósitos, retiros y validaciones anteriores también deben seguir funcionando.
Una transferencia sin saldo suficiente no debe modificar ninguna de las dos cuentas. Esta es una invariante importante del dominio.
Archivo a modificar: tests/test_cuenta.py
import pytest
def test_transferencia_sin_saldo_no_modifica_las_cuentas():
origen = CuentaBancaria()
destino = CuentaBancaria()
origen.depositar(30)
with pytest.raises(ValueError, match="Saldo insuficiente"):
origen.transferir_a(destino, 50)
assert origen.saldo == 30
assert destino.saldo == 0
assert origen.movimientos == [Movimiento("deposito", 30)]
assert destino.movimientos == []
Esta prueba es más fuerte que comprobar solamente la excepción. También verifica que el estado del sistema quedó consistente.
El modelo evolucionó en pasos pequeños:
Movimiento.Ninguna de estas decisiones fue tomada por anticipado. Cada una apareció porque una prueba concreta hizo visible una necesidad.
En un proyecto real podríamos imaginar muchas características: fechas, monedas, usuarios, cuentas bloqueadas, auditoría, límites diarios y persistencia en base de datos. En TDD no agregamos todo eso por suposición.
Las pruebas deberían leerse como ejemplos del negocio. Nombres como
test_transferencia_mueve_saldo_entre_cuentas o
test_transferencia_sin_saldo_no_modifica_las_cuentas comunican reglas mejor
que nombres genéricos.
Esto ayuda a mantener el curso del diseño: si cuesta nombrar la prueba, posiblemente el comportamiento todavía no está claro.
Agregá una regla nueva usando TDD: una cuenta puede tener un límite máximo por transferencia. Trabajá en pasos pequeños.
python -m pytest.TDD permite que el modelo de dominio evolucione con control. Las pruebas no solo verifican que el código funcione: también ayudan a descubrir qué conceptos merecen existir, qué reglas deben protegerse y qué nombres hacen más claro el diseño.
En el próximo tema veremos cómo usar fixtures de forma práctica sin acoplar las pruebas al diseño interno del código.