TDD y pruebas automatizadas

"Si una funcionalidad no tiene prueba, no existe. Porque tarde o temprano alguien la romperá sin saberlo."

Qué vas a aprender en este capítulo

Test-Driven Development (TDD) invierte el orden habitual: primero escribís la prueba (que falla), luego escribís el código mínimo para que pase, luego refactorizás. Este ciclo produce código más limpio, mejor diseñado y con cobertura de pruebas incorporada desde el inicio. Este capítulo muestra TDD en la práctica con Python y pytest.


4.1 El ciclo Red-Green-Refactor

💡 Intuición

TDD tiene tres pasos, repetidos en ciclos cortos (minutos, no horas):

  1. Red: Escribís una prueba que describe el comportamiento deseado. La ejecutás. Falla (rojo) porque el código no existe todavía. Esto es correcto y esperado.

  2. Green: Escribís el código mínimo (el más simple posible) para que la prueba pase. No más. La ejecutás. Pasa (verde).

  3. Refactor: Mejorás el código sin cambiar su comportamiento. Las pruebas siguen pasando, confirmando que no rompiste nada.

Luego repetís con la siguiente funcionalidad.

Por qué funciona:

  • Las pruebas documentan el comportamiento esperado.
  • Forzás a pensar en la API antes de implementar.
  • El código resultante es más modular (si es difícil de testear, es difícil de usar).
  • Tenés una red de seguridad para refactorizar sin miedo.

📐 Fundamento

El ciclo TDD formal:

[Red] Escribir una prueba que falla
    ↓
[Green] Escribir el código mínimo para pasar
    ↓
[Refactor] Limpiar sin cambiar comportamiento
    ↓
[Repetir con siguiente prueba]

Reglas del TDD (Kent Beck):

  1. No escribir código de producción a menos que sea para hacer pasar una prueba que falla.
  2. No escribir más prueba que la suficiente para demostrar un fallo.
  3. No escribir más código de producción que el suficiente para hacer pasar la prueba.

FIRST — principios de buenas pruebas unitarias:

  • Fast: Cada prueba corre en milisegundos. El suite completo en segundos.
  • Isolated: No dependen entre sí. Pueden correr en cualquier orden.
  • Repeatable: Mismo resultado en cualquier entorno.
  • Self-validating: Pass o Fail. No requiere inspección manual.
  • Timely: Se escriben cuando el código existe (o antes, en TDD).

4.2 Pruebas con pytest

💡 Intuición

pytest es el framework de pruebas más popular en Python. Es simple de empezar y poderoso cuando lo necesitás.

Una prueba en pytest es simplemente una función que empieza con test_. Si no lanza una excepción, pasa. Si lanza AssertionError (o cualquier excepción), falla.

📐 Fundamento

Instalación y estructura:

pip install pytest
proyecto/
├── src/
│   └── pedido.py
└── tests/
    └── test_pedido.py

Estructura básica de una prueba:

# tests/test_pedido.py

def test_calcular_total_con_iva():
    # Arrange (preparar)
    pedido = Pedido()
    pedido.agregar(Platillo("pupusa de queso", 1.00), cantidad=3)
    
    # Act (actuar)
    total = pedido.calcular_total()
    
    # Assert (verificar)
    assert total == pytest.approx(3.39, rel=0.01)

El patrón Arrange-Act-Assert (AAA) organiza cada prueba en tres secciones claras.

Fixtures — reutilizar configuración:

import pytest

@pytest.fixture
def pedido_basico():
    pedido = Pedido()
    pedido.agregar(Platillo("pupusa", 1.00), cantidad=2)
    return pedido

def test_total_dos_pupusas(pedido_basico):
    assert pedido_basico.calcular_total() == pytest.approx(2.26)

def test_tiene_dos_items(pedido_basico):
    assert len(pedido_basico.items) == 2

Parametrización — probar múltiples casos:

@pytest.mark.parametrize("cantidad, precio, esperado", [
    (1, 1.00, 1.13),
    (2, 2.50, 5.65),
    (3, 0.75, 2.54),
])
def test_calcular_total_parametrizado(cantidad, precio, esperado):
    pedido = Pedido()
    pedido.agregar(Platillo("platillo", precio), cantidad=cantidad)
    assert pedido.calcular_total() == pytest.approx(esperado, rel=0.01)

Probar excepciones:

def test_pedido_vacio_lanza_error():
    pedido = Pedido()
    with pytest.raises(PedidoVacioError, match="El pedido debe tener"):
        pedido.enviar_a_cocina()

Ejecutar las pruebas:

pytest                     # todas las pruebas
pytest tests/test_pedido.py  # archivo específico
pytest -v                  # verbose (ver nombre de cada prueba)
pytest --cov=src           # con cobertura (requiere pytest-cov)

🛠️ En la práctica

TDD en acción — Implementar calcular_total():

Paso 1 (Red): Escribir la prueba antes de tener la clase.

# tests/test_pedido.py
from src.pedido import Pedido, Platillo

def test_total_de_un_platillo():
    pedido = Pedido()
    pupusa = Platillo("pupusa de queso", precio=1.00)
    pedido.agregar(pupusa, cantidad=1)
    assert pedido.calcular_total() == pytest.approx(1.13)

Correr: pytestFAIL (ImportError: no hay módulo pedido). Rojo. ✓

Paso 2 (Green): Escribir el código mínimo.

# src/pedido.py
IVA = 0.13

class Platillo:
    def __init__(self, nombre, precio):
        self.nombre = nombre
        self.precio = precio

class Pedido:
    def __init__(self):
        self._items = []

    def agregar(self, platillo, cantidad):
        self._items.append((platillo, cantidad))

    def calcular_total(self):
        subtotal = sum(p.precio * c for p, c in self._items)
        return subtotal * (1 + IVA)

Correr: pytestPASS. Verde. ✓

Paso 3 (Refactor): El código es simple y limpio. No necesita refactor aún. Agregar la siguiente prueba.

def test_pedido_vacio_tiene_total_cero():
    pedido = Pedido()
    assert pedido.calcular_total() == 0.0

Correr: PASS inmediatamente (el código ya lo maneja). ✓

def test_multiples_platillos():
    pedido = Pedido()
    pedido.agregar(Platillo("pupusa", 1.00), 2)
    pedido.agregar(Platillo("refresco", 0.75), 1)
    # subtotal = 2.75, total con IVA = 3.1075
    assert pedido.calcular_total() == pytest.approx(3.11, rel=0.01)

Correr: PASS. ✓

En minutos, tenemos una clase probada, funcional y con 3 casos cubiertos.


4.3 Mocks y stubs

💡 Intuición

Las pruebas unitarias deben ser aisladas. Pero el código real tiene dependencias: bases de datos, APIs externas, sistemas de email. No podés (y no deberías) llamar a una BD real en cada prueba unitaria.

Mocks y stubs reemplazan esas dependencias por versiones de mentira ("dobles de prueba"):

  • Stub: Una dependencia que devuelve valores fijos. No verifica si fue llamada.
  • Mock: Una dependencia que además verifica que fue llamada de la forma correcta.

Metáfora: en una película, los actores no se lanzan de aviones reales — usan stuntmen (stubs/mocks). El resultado en cámara es el mismo, pero el proceso es controlado.

📐 Fundamento

unittest.mock en Python (incluido en la librería estándar):

from unittest.mock import Mock, patch, MagicMock

# Crear un mock básico
repo_mock = Mock()
repo_mock.guardar.return_value = True  # definir valor de retorno
repo_mock.buscar.return_value = None   # otro caso

# Verificar que fue llamado
repo_mock.guardar.assert_called_once()
repo_mock.buscar.assert_called_with(id=42)

Inyección de dependencias para facilitar testing:

# Difícil de testear (dependencia interna):
class PedidoService:
    def __init__(self):
        self.repo = MySQLPedidoRepository()  # acoplado a MySQL

# Fácil de testear (dependencia inyectada):
class PedidoService:
    def __init__(self, repo):  # puede recibir un mock en pruebas
        self.repo = repo

Patch — reemplazar temporalmente un módulo:

from unittest.mock import patch

def test_envio_email_al_confirmar():
    with patch('src.notificaciones.enviar_email') as mock_email:
        servicio = PedidoService(repo=Mock())
        servicio.confirmar_pedido(pedido_id=1)
        
        mock_email.assert_called_once_with(
            destinatario="cocina@laesquina.sv",
            asunto="Nuevo pedido #1"
        )

🛠️ En la práctica

Testear PedidoService.enviar_a_cocina() sin BD real:

# src/services.py
class PedidoService:
    def __init__(self, repo, notificador):
        self.repo = repo
        self.notificador = notificador

    def enviar_a_cocina(self, pedido_id):
        pedido = self.repo.buscar(pedido_id)
        if pedido is None:
            raise PedidoNoEncontradoError(pedido_id)
        pedido.estado = "en_preparacion"
        self.repo.guardar(pedido)
        self.notificador.notificar_cocina(pedido)
        return pedido
# tests/test_services.py
def test_enviar_a_cocina_cambia_estado():
    # Arrange
    pedido_fake = Pedido()
    pedido_fake.id = 1
    pedido_fake.items = [...]
    
    repo_mock = Mock()
    repo_mock.buscar.return_value = pedido_fake
    
    notif_mock = Mock()
    
    servicio = PedidoService(repo=repo_mock, notificador=notif_mock)
    
    # Act
    pedido_resultado = servicio.enviar_a_cocina(pedido_id=1)
    
    # Assert
    assert pedido_resultado.estado == "en_preparacion"
    repo_mock.guardar.assert_called_once_with(pedido_fake)
    notif_mock.notificar_cocina.assert_called_once_with(pedido_fake)

def test_enviar_a_cocina_pedido_inexistente():
    repo_mock = Mock()
    repo_mock.buscar.return_value = None
    servicio = PedidoService(repo=repo_mock, notificador=Mock())
    
    with pytest.raises(PedidoNoEncontradoError):
        servicio.enviar_a_cocina(pedido_id=999)

En ningún momento tocamos una BD real. Las pruebas corren en milisegundos.


4.4 Cobertura de código

📐 Fundamento

Code coverage mide qué porcentaje del código fuente es ejecutado por las pruebas.

Tipos de cobertura:

Tipo Mide
Statement coverage % de sentencias ejecutadas
Branch coverage % de ramas (if/else) ejecutadas en ambas direcciones
Function coverage % de funciones llamadas
Line coverage % de líneas ejecutadas

Con pytest-cov:

pip install pytest-cov
pytest --cov=src --cov-report=html
# Genera reporte HTML en htmlcov/index.html

Objetivos típicos de cobertura:

  • < 60%: Peligroso. Cambios pueden romper cosas sin detección.
  • 60–80%: Aceptable para proyectos pequeños.
  • 80–90%: Objetivo estándar para proyectos profesionales.
  • 90%: Generalmente diminishing returns (cobertura del 100% es imposible en la práctica).

Importante: 80% de cobertura no significa que el 80% del código sea correcto. Solo que el 80% es ejecutado por las pruebas. Podés tener alta cobertura con pruebas que no verifican el comportamiento correctamente.


4.5 BDD (Behavior-Driven Development)

💡 Intuición

BDD extiende TDD hacia el dominio del negocio. En lugar de escribir pruebas en código Python, las escribís en lenguaje natural (inglés o español) usando el formato Dado/Cuando/Entonces (Given/When/Then).

¿Para qué? Para que el Product Owner, el analista y el desarrollador lean la misma especificación. Las historias de usuario se convierten directamente en especificaciones ejecutables.

Herramienta: Cucumber (o Behave para Python).

📐 Fundamento

Formato Gherkin:

# features/registrar_pedido.feature
Funcionalidad: Registrar pedido en La Esquina

  Escenario: Mozo registra un pedido válido
    Dado que el mozo "Juan" ha iniciado sesión
    Y que la mesa 3 está disponible
    Cuando el mozo agrega 2 pupusas de queso al pedido
    Y confirma el pedido
    Entonces el pedido aparece en la pantalla de cocina
    Y el estado del pedido es "en preparación"

  Escenario: Mozo intenta enviar un pedido vacío
    Dado que el mozo "Juan" ha iniciado sesión
    Cuando el mozo intenta confirmar sin agregar platillos
    Entonces el sistema muestra "Agrega al menos un platillo"

Implementación en Python con Behave:

# features/steps/pedido_steps.py
from behave import given, when, then

@given('que el mozo "{nombre}" ha iniciado sesión')
def step_impl(context, nombre):
    context.mozo = Mozo.autenticar(nombre, password="test")

@when('el mozo agrega {cantidad:d} pupusas de queso al pedido')
def step_impl(context, cantidad):
    pupusa = Platillo("pupusa de queso", 1.00)
    context.pedido.agregar(pupusa, cantidad)

@then('el pedido aparece en la pantalla de cocina')
def step_impl(context):
    assert context.cocina_mock.tiene_pedido(context.pedido.id)

4.6 Ejercicios

✏️ Ejercicio 4.1 — TDD básico

Implementá usando TDD (ciclo Red-Green-Refactor) una función calcular_iva(monto) que:

  • Retorna el IVA del 13% del monto dado.
  • Lanza ValueError si el monto es negativo.
  • Usa Decimal para evitar errores de punto flotante.

Escribí las pruebas primero, luego la implementación.

✏️ Ejercicio 4.2 — Mocks

El método PedidoService.cerrar_cuenta(mesa_id) debe:

  1. Buscar el pedido activo de la mesa.
  2. Calcular el total con IVA.
  3. Crear un registro de factura en el repositorio de facturas.
  4. Marcar el pedido como "cerrado".
  5. Enviar un email de resumen al administrador.

Escribí las pruebas para este método usando mocks. No necesitás implementarlo.


4.7 Para profundizar

4.X Errores comunes

⚠️ Trampa común

Confundir "100 % de cobertura" con "código bien probado". Cobertura mide qué líneas se ejecutaron durante los tests, no si están bien probadas. Podés tener 100 % de cobertura con tests que no afirman nada (assert True) o con tests que solo cubren el camino feliz. El valor de cobertura es piso, no techo.

Tip: apuntá a 80-90 % de cobertura con asserts significativos. Lo que más vale: mutation testing (mutmut, pitest) — cambia el código y verifica que algún test falle. Si no falla, el código no está realmente probado.

⚠️ Trampa común

Mockear todo, incluso lo que no hace falta. Mockear la base de datos para un test que valida una función pura es un anti-patrón: hace que el test no falle aunque la lógica esté mal, porque el mock devuelve lo que vos quieras. Y al refactorizar, mil mocks rotos.

Tip: la regla de la pirámide de testing: muchos unit tests sin mocks (sobre funciones puras), pocos integration tests con DB real, muy pocos end-to-end con UI. Si necesitás 5 mocks para un test, posiblemente la función esté haciendo demasiado.


Definiciones nuevas: TDD, Red-Green-Refactor, AAA (Arrange-Act-Assert), FIRST, fixture, parametrización, mock, stub, inyección de dependencias, code coverage, branch coverage, BDD, Gherkin, Cucumber/Behave.