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):
-
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.
-
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).
-
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):
- No escribir código de producción a menos que sea para hacer pasar una prueba que falla.
- No escribir más prueba que la suficiente para demostrar un fallo.
- 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: pytest → FAIL (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: pytest → PASS. 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
ValueErrorsi el monto es negativo. - Usa
Decimalpara evitar errores de punto flotante.
Escribí las pruebas primero, luego la implementación.
Solución
Paso 1 (Red) — Pruebas:
from decimal import Decimal
import pytest
from src.fiscal import calcular_iva
def test_iva_monto_positivo():
assert calcular_iva(Decimal("100.00")) == Decimal("13.00")
def test_iva_monto_con_centavos():
assert calcular_iva(Decimal("27.50")) == Decimal("3.575")
def test_iva_monto_cero():
assert calcular_iva(Decimal("0")) == Decimal("0")
def test_iva_monto_negativo_lanza_error():
with pytest.raises(ValueError, match="negativo"):
calcular_iva(Decimal("-10.00"))
Paso 2 (Green) — Implementación mínima:
# src/fiscal.py
from decimal import Decimal
IVA_RATE = Decimal("0.13")
def calcular_iva(monto: Decimal) -> Decimal:
if monto < 0:
raise ValueError("El monto no puede ser negativo")
return monto * IVA_RATE
Paso 3 (Refactor): El código es limpio. No requiere cambios.
✏️ Ejercicio 4.2 — Mocks
El método PedidoService.cerrar_cuenta(mesa_id) debe:
- Buscar el pedido activo de la mesa.
- Calcular el total con IVA.
- Crear un registro de factura en el repositorio de facturas.
- Marcar el pedido como "cerrado".
- Enviar un email de resumen al administrador.
Escribí las pruebas para este método usando mocks. No necesitás implementarlo.
Solución
def test_cerrar_cuenta_flujo_completo():
# Arrange
pedido_mock = Mock()
pedido_mock.mesa_id = 5
pedido_mock.calcular_total.return_value = Decimal("45.20")
repo_pedidos = Mock()
repo_pedidos.buscar_activo_por_mesa.return_value = pedido_mock
repo_facturas = Mock()
notificador = Mock()
servicio = PedidoService(
repo_pedidos=repo_pedidos,
repo_facturas=repo_facturas,
notificador=notificador
)
# Act
servicio.cerrar_cuenta(mesa_id=5)
# Assert — verificar que se llamaron los colaboradores correctamente
repo_pedidos.buscar_activo_por_mesa.assert_called_once_with(5)
pedido_mock.calcular_total.assert_called_once()
repo_facturas.crear.assert_called_once() # con el total correcto
assert pedido_mock.estado == "cerrado"
notificador.enviar_resumen_admin.assert_called_once()
def test_cerrar_cuenta_mesa_sin_pedido():
repo_pedidos = Mock()
repo_pedidos.buscar_activo_por_mesa.return_value = None
servicio = PedidoService(repo_pedidos=repo_pedidos, repo_facturas=Mock(), notificador=Mock())
with pytest.raises(MesaSinPedidoActivoError):
servicio.cerrar_cuenta(mesa_id=99)
4.7 Para profundizar
- Beck, Test Driven Development: By Example — el libro original de TDD.
- Meszaros, xUnit Test Patterns — patrones de pruebas (mocks, stubs, fixtures).
- Documentación de pytest: docs.pytest.org
- Siguiente: CI/CD y DevOps — automatizar el despliegue con pruebas.
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.