16. TDD con clases pequeñas: estado, invariantes y métodos públicos

16.1 Objetivo del tema

En este tema aplicaremos TDD a una clase pequeña. A diferencia de una función pura, una clase puede tener estado: sus métodos pueden modificar datos internos que se mantienen entre llamadas.

Trabajaremos con una CuentaBancaria para practicar creación de objetos, métodos públicos e invariantes.

Objetivo práctico: diseñar una clase pequeña desde pruebas, verificando su comportamiento público sin depender de detalles internos innecesarios.

16.2 Qué es estado

El estado es la información que un objeto conserva. En una cuenta bancaria, el estado principal será el saldo.

Si depositamos dinero, el saldo cambia. Si retiramos dinero, el saldo también cambia. Las pruebas deben observar esos cambios mediante métodos o propiedades públicas.

16.3 Qué es una invariante

Una invariante es una regla que siempre debe cumplirse para que el objeto sea válido.

Invariante: el saldo de la cuenta no puede ser negativo.

Las pruebas nos ayudarán a proteger esa regla.

16.4 Primer requisito

Empezaremos con una regla simple:

Una cuenta nueva comienza con saldo 0.

Primero escribimos la prueba.

16.5 Primera prueba

Archivo a crear: tests/test_cuenta_bancaria.py

from banco.cuenta import CuentaBancaria


def test_cuenta_nueva_comienza_con_saldo_cero():
    cuenta = CuentaBancaria()

    assert cuenta.saldo == 0

Ejecutamos:

python -m pytest

La prueba debe fallar porque la clase todavía no existe.

16.6 Implementación mínima de la clase

Archivo a crear: src/banco/cuenta.py

class CuentaBancaria:
    def __init__(self):
        self.saldo = 0

Ejecutamos python -m pytest. La primera prueba debería pasar.

16.7 Segundo requisito: depositar dinero

Agregamos comportamiento:

Al depositar dinero, el saldo aumenta en el importe depositado.

La prueba debe usar el método público que queremos diseñar.

16.8 Prueba para depósito

Archivo a modificar: tests/test_cuenta_bancaria.py

from banco.cuenta import CuentaBancaria


def test_depositar_aumenta_el_saldo():
    cuenta = CuentaBancaria()

    cuenta.depositar(100)

    assert cuenta.saldo == 100

Ejecutamos python -m pytest. La prueba debe fallar porque falta depositar.

16.9 Implementar depósito

Archivo a modificar: src/banco/cuenta.py

class CuentaBancaria:
    def __init__(self):
        self.saldo = 0

    def depositar(self, importe):
        self.saldo += importe

Ejecutamos la suite. Si pasa, seguimos en verde.

16.10 Tercer requisito: retirar dinero

Agregamos otra operación pública:

Al retirar dinero, el saldo disminuye en el importe retirado.

16.11 Prueba para retiro

Archivo a modificar: tests/test_cuenta_bancaria.py

from banco.cuenta import CuentaBancaria


def test_retirar_disminuye_el_saldo():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)

    cuenta.retirar(40)

    assert cuenta.saldo == 60

Ejecutamos python -m pytest. La prueba debe fallar porque falta retirar.

16.12 Implementar retiro

Archivo a modificar: src/banco/cuenta.py

class CuentaBancaria:
    def __init__(self):
        self.saldo = 0

    def depositar(self, importe):
        self.saldo += importe

    def retirar(self, importe):
        self.saldo -= importe

Ejecutamos python -m pytest. Las pruebas deberían pasar.

16.13 Proteger la invariante

Ahora escribimos una prueba para la regla principal: el saldo no puede quedar negativo.

Archivo a modificar: tests/test_cuenta_bancaria.py

import pytest

from banco.cuenta import CuentaBancaria


def test_no_permite_retirar_mas_que_el_saldo_disponible():
    cuenta = CuentaBancaria()
    cuenta.depositar(50)

    with pytest.raises(ValueError):
        cuenta.retirar(80)

Ejecutamos python -m pytest. La prueba debe fallar porque la implementación actual permite saldo negativo.

16.14 Implementar la invariante

Archivo a modificar: src/banco/cuenta.py

class CuentaBancaria:
    def __init__(self):
        self.saldo = 0

    def depositar(self, importe):
        self.saldo += importe

    def retirar(self, importe):
        if importe > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= importe

Ejecutamos nuevamente la suite.

python -m pytest

16.15 Verificar que el saldo no cambia ante error

Además de lanzar excepción, el saldo debe quedar igual.

Archivo a modificar: tests/test_cuenta_bancaria.py

def test_retiro_rechazado_no_modifica_el_saldo():
    cuenta = CuentaBancaria()
    cuenta.depositar(50)

    with pytest.raises(ValueError):
        cuenta.retirar(80)

    assert cuenta.saldo == 50

Esta prueba protege el estado después de una operación inválida.

16.16 Validar depósitos inválidos

No debería permitirse depositar cero o valores negativos.

Archivo a modificar: tests/test_cuenta_bancaria.py

@pytest.mark.parametrize("importe", [0, -10])
def test_no_permite_depositos_no_positivos(importe):
    cuenta = CuentaBancaria()

    with pytest.raises(ValueError):
        cuenta.depositar(importe)

Ejecutamos python -m pytest. La prueba debe fallar hasta que agreguemos la validación.

16.17 Implementar validación de depósito

Archivo a modificar: src/banco/cuenta.py

class CuentaBancaria:
    def __init__(self):
        self.saldo = 0

    def depositar(self, importe):
        if importe <= 0:
            raise ValueError("El depósito debe ser positivo")
        self.saldo += importe

    def retirar(self, importe):
        if importe > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= importe

Ejecutamos la suite completa.

16.18 Validar retiros inválidos

También deberíamos rechazar retiros de cero o valores negativos.

Archivo a modificar: tests/test_cuenta_bancaria.py

@pytest.mark.parametrize("importe", [0, -10])
def test_no_permite_retiros_no_positivos(importe):
    cuenta = CuentaBancaria()
    cuenta.depositar(100)

    with pytest.raises(ValueError):
        cuenta.retirar(importe)

Esta prueba nos guía hacia una validación similar en retirar.

16.19 Refactor: extraer validación de importe

Como depósito y retiro comparten una regla, podemos extraer una función privada simple.

Archivo a modificar: 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")

Ejecutamos python -m pytest. No probamos directamente el método privado; verificamos el comportamiento desde métodos públicos.

16.20 Errores frecuentes

  • Probar atributos internos innecesarios: conviene observar comportamiento público.
  • Agregar muchos métodos antes de tener pruebas: rompe el ritmo de TDD.
  • No proteger invariantes: el objeto puede quedar en un estado inválido.
  • Olvidar verificar estado después de errores: una excepción no debería dejar el objeto corrupto.
  • Probar métodos privados: suele hacer más frágil la suite.

16.21 Ejercicio propuesto

Agrega un método transferir_a que reciba otra cuenta y un importe. Debe retirar de la cuenta actual y depositar en la cuenta destino.

  • Escribe primero una prueba con dos cuentas.
  • Verifica los saldos finales de ambas cuentas.
  • Agrega una prueba para saldo insuficiente.
  • Comprueba que ante error ninguna cuenta cambia de saldo.
  • Ejecuta python -m pytest después de cada paso.

16.22 Lista de verificación

Antes de continuar, verifica lo siguiente:

  • Probaste el estado inicial del objeto.
  • Probaste cambios de estado mediante métodos públicos.
  • Protegiste invariantes con pruebas.
  • Verificaste el estado después de operaciones inválidas.
  • Evitas probar métodos privados directamente.
  • Ejecutaste python -m pytest después de cada cambio.

16.23 Conclusión

En este tema usamos TDD para diseñar una clase pequeña con estado. Agregamos métodos públicos paso a paso, protegimos invariantes y refactorizamos validaciones compartidas sin probar detalles privados.

En el próximo tema veremos cómo un modelo de dominio puede evolucionar con pruebas primero.