17. Evolución de un modelo de dominio con pruebas primero

17.1 Objetivo del tema

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.

17.2 Qué entendemos por modelo de dominio

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 este ejemplo, palabras como cuenta, saldo, depósito, retiro, movimiento y transferencia forman parte del lenguaje del dominio.

En TDD, ese lenguaje no aparece completo de una vez. Lo descubrimos a medida que escribimos pruebas que expresan comportamientos concretos.

17.3 Punto de partida

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.

17.4 Nueva necesidad del dominio

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.

Requisito: cuando una cuenta recibe un depósito, el movimiento debe quedar registrado con su tipo y su importe.

17.5 Primera prueba: registrar un depósito

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.

17.6 Implementación mínima

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.

17.7 Segundo comportamiento: registrar un retiro

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.

17.8 Código mínimo para registrar retiros

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.

17.9 Una señal de diseño

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.

En TDD, el refactor se hace con pruebas pasando. Primero confirmamos comportamiento; después mejoramos la forma del código.

17.10 Refactor: crear un objeto Movimiento

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.

17.11 Ajustar las pruebas al nuevo modelo

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.

17.12 Nueva necesidad: transferir dinero

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.

17.13 Implementar transferencia reutilizando comportamiento

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.

17.14 Hacer visible una regla pendiente

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.

17.15 Cambiar la implementación sin romper reglas anteriores

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.

17.16 Proteger una invariante

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.

17.17 Diseño incremental

El modelo evolucionó en pasos pequeños:

  • Primero representaba saldo.
  • Luego registró depósitos y retiros.
  • Después apareció el concepto Movimiento.
  • Finalmente incorporó transferencias e invariantes entre dos cuentas.

Ninguna de estas decisiones fue tomada por anticipado. Cada una apareció porque una prueba concreta hizo visible una necesidad.

17.18 Evitar diseño prematuro

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.

Una buena práctica es agregar complejidad solo cuando una prueba expresa un comportamiento que realmente la necesita.

17.19 Pruebas que hablan el lenguaje del dominio

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.

17.20 Errores frecuentes

  • Crear muchas clases antes de tener pruebas que las justifiquen.
  • Probar detalles internos que no forman parte del lenguaje del dominio.
  • Modificar varias reglas al mismo tiempo y perder claridad sobre qué falló.
  • Agregar campos futuros que todavía no tienen comportamiento comprobado.
  • Refactorizar con pruebas en rojo.

17.21 Ejercicio práctico

Agregá una regla nueva usando TDD: una cuenta puede tener un límite máximo por transferencia. Trabajá en pasos pequeños.

  1. Escribí una prueba para una transferencia dentro del límite permitido.
  2. Escribí una prueba para una transferencia que supera el límite.
  3. Implementá el código mínimo para pasar ambas pruebas.
  4. Verificá que el saldo y los movimientos no cambien cuando la operación se rechaza.
  5. Ejecutá toda la suite con python -m pytest.

17.22 Checklist del tema

  • El modelo crece a partir de pruebas concretas.
  • Los nombres de métodos y pruebas usan lenguaje del dominio.
  • Las invariantes se prueban junto con el estado final.
  • El refactor se realiza después de llegar a verde.
  • No se agrega complejidad sin una necesidad comprobada.

17.23 Conclusión

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.