Papyrus Online funciona — o eso parece cuando lo pruebas a mano. Pero "lo probé a mano el martes" no es verificación: es una anécdota. Esta lección convierte los criterios de aceptación de 12-01 en una suite de tests ejecutable, la que te permite tocar cualquier pieza del sistema y saber en veinte segundos si has roto algo. Aplicaremos la pirámide de M9 al proyecto completo — qué se prueba en cada nivel y con qué herramienta —, escribiremos el test de integración que recorre todo el flujo (vender → persistir → releer → cuadrar caja), probaremos la interfaz con su cliente de pruebas, y aprenderemos a depurar a través de las capas cuando un test diga que no. Al final: la checklist de calidad que decide si el proyecto está listo para entregarse.
Contenido
- La pirámide aplicada a Papyrus Online
- Datos de prueba vs datos reales: las fixtures canónicas
- La suite mínima exigible (criterios → tests)
- El test de integración del flujo completo
- Probar la interfaz: test client de Flask / Client de Django
- Regresiones: cada bug deja su test
- Depurar el sistema integrado
- Checklist final de calidad
La pirámide aplicada a Papyrus Online
En 09-01 la pirámide era teoría; ahora tiene nombres y apellidos. Cada nivel prueba una cosa distinta, con una herramienta distinta, y en la proporción correcta (muchos abajo, pocos arriba):
| Nivel | Qué se prueba AQUÍ | Herramienta | Cuántos | Velocidad |
|---|---|---|---|---|
| Unitario (dominio) | precio_final, aplicar_cupon, hay_stock, construcción de Libro/Socio |
pytest puro, sin disco ni red | 10-15 | milisegundos |
| Servicio | ServicioVentas completo: las tres fases, cada error, cada combinación socio/cupón |
pytest + repositorios sobre tmp_path |
8-12 | rápidos |
| Integración | El flujo entero: vender → persistir → releer → cierre de caja cuadra | pytest + tmp_path |
2-3 | moderados |
| Interfaz | Cada ruta devuelve su código y su JSON/HTML | test client (Flask) / Client (Django) |
5-8 | moderados |
La regla de reparto: si un comportamiento puede probarse en un nivel inferior, se prueba ahí. El redondeo de 12.35 se verifica en el nivel unitario, no lanzando peticiones HTTP; el endpoint solo verifica que traduce bien (JSON correcto, código correcto), porque el fondo ya está probado más abajo. Probar el redondeo por HTTP funciona, pero es lento, frágil y, cuando falla, no te dice dónde está el bug.
Datos de prueba vs datos reales
Regla absoluta: los tests jamás tocan datos/. Un test que escribe en tu catálogo real es una bomba: corrompe tus datos o, peor, pasa o falla según lo que vendiste ayer. Todos los tests trabajan sobre tmp_path (M9) con las fixtures canónicas — los cuatro libros de siempre, precisamente porque llevas once módulos conociendo sus números de memoria:
# tests/conftest.py
import json
import pytest
from papyrus.repositorios import (RegistroVentas, RepositorioCatalogo,
RepositorioSocios)
from papyrus.servicios import ServicioVentas
CATALOGO = [
{"titulo": "La Odisea", "autor": "Homero", "precio": 12.50, "stock": 4},
{"titulo": "Hamlet", "autor": "Shakespeare", "precio": 9.95, "stock": 6},
{"titulo": "El Quijote", "autor": "Cervantes", "precio": 15.90, "stock": 8},
{"titulo": "Fausto", "autor": "Goethe", "precio": 21.00, "stock": 10},
]
SOCIOS = [{"codigo": "LUIS-001", "nombre": "Luis", "alta": "2025-03-12"},
{"codigo": "MARTA-002", "nombre": "Marta", "alta": "2025-06-30"},
{"codigo": "PAU-003", "nombre": "Pau", "alta": "2026-01-15"}]
@pytest.fixture
def rutas(tmp_path):
"""Un datos/ de mentira, recién creado para CADA test (aislamiento)."""
(tmp_path / "catalogo.json").write_text(
json.dumps(CATALOGO, ensure_ascii=False), encoding="utf-8")
(tmp_path / "socios.json").write_text(
json.dumps(SOCIOS, ensure_ascii=False), encoding="utf-8")
return tmp_path
@pytest.fixture
def servicio(rutas):
"""Un ServicioVentas montado sobre el datos/ de mentira."""
return ServicioVentas(RepositorioCatalogo(rutas / "catalogo.json"),
RepositorioSocios(rutas / "socios.json"),
RegistroVentas(rutas / "ventas.csv"))Aquí cobra sentido la decisión de diseño de 12-02: como el servicio recibe los repositorios ya construidos, los tests le inyectan repositorios de tmp_path sin tocar una línea del código de producción. Si tu servicio hubiera abierto "datos/catalogo.json" a pelo, esta lección sería imposible. El código fácil de probar y el código bien diseñado son el mismo código.
La suite mínima exigible
Los criterios de aceptación de 12-01, uno a uno, convertidos en tests con nombre. Esta tabla es tu RNF1: la suite está completa cuando cada fila existe y pasa:
| Test | Requisito que verifica |
|---|---|
test_precio_final_socio_y_no_socio (parametrizado: los 8 importes canónicos) |
RF2 |
test_alta_libro_duplicado_falla |
RF1 |
test_borrar_libro_y_consultarlo_lanza_no_encontrado |
RF1 |
test_vender_a_socio_valido_aplica_descuento (12.35 con LUIS-001) |
RF2, RF3 |
test_vender_con_socio_desconocido_lanza_socio_invalido |
RF2 |
test_vender_sin_stock_lanza_error_y_no_modifica_nada |
RF3 |
test_vender_con_cupon_valido_e_invalido (18.67 / CuponInvalidoError) |
RF3 |
test_venta_persiste_y_se_relee (el de integración, abajo) |
RF4 |
test_guardar_es_atomico_no_deja_json_corrupto |
RF4 |
test_api_rutas_devuelven_codigos_correctos (o vistas Django) |
RF5 |
test_informe_reproduce_numeros_canonicos (520 u, sábado 130) |
RF6 |
test_venta_rechazada_deja_warning_en_log (con caplog) |
RF7 |
Con las parametrizaciones, esto suma unos 25 tests. No es un número mágico: es la traducción literal del contrato de 12-01. Si mañana añades un requisito, su fila aparece aquí antes que su código — eso era TDD (09-03) y este proyecto es el mejor sitio para practicarlo.
El test de integración del flujo completo
El test más valioso del proyecto: recorre las cuatro capas de datos (servicio → disco → releer → agregar) y comprueba que la historia completa cuadra. Completo y comentado:
# tests/test_integracion.py
from datetime import date
from papyrus.repositorios import (RegistroVentas, RepositorioCatalogo,
RepositorioSocios)
from papyrus.servicios import ServicioVentas
def test_flujo_completo_vender_persistir_releer_cuadrar(rutas):
# --- 1. VENDER: socio + cupón, dos ventas ---
servicio = ServicioVentas(RepositorioCatalogo(rutas / "catalogo.json"),
RepositorioSocios(rutas / "socios.json"),
RegistroVentas(rutas / "ventas.csv"))
v1 = servicio.vender("Fausto", 1, codigo_socio="LUIS-001", cupon="PAPYRUS10")
v2 = servicio.vender("Hamlet", 1) # Júlia, sin socio
assert v1.importe == 18.67 # 21.00 ×1.04 ×0.95 ×0.90
assert v2.importe == 10.35 # 9.95 ×1.04
# --- 2. PERSISTIR y RELEER: una instancia NUEVA, que solo ve el disco ---
# Si el stock es correcto aquí, es porque guardar() y cargar() funcionan
# de verdad, no porque el objeto viejo lo recordara en memoria.
catalogo_releido = RepositorioCatalogo(rutas / "catalogo.json").cargar()
assert catalogo_releido["Fausto"].stock == 9 # era 10
assert catalogo_releido["Hamlet"].stock == 5 # era 6
# --- 3. CUADRAR: el cierre de caja sale del CSV, no de la memoria ---
servicio_nuevo = ServicioVentas(RepositorioCatalogo(rutas / "catalogo.json"),
RepositorioSocios(rutas / "socios.json"),
RegistroVentas(rutas / "ventas.csv"))
hoy = date.today().isoformat()
assert servicio_nuevo.cierre_de_caja(hoy) == 29.02 # 18.67 + 10.35El detalle que lo hace un test de integración de verdad está en el paso 2: la instancia nueva. Si reutilizas el servicio original, el test puede pasar aunque guardar() esté rota, porque el objeto recuerda el stock en memoria. Releer desde disco es lo que demuestra RF4.
Probar la interfaz
Itinerario A — test client de Flask (10-03). Para inyectar el tmp_path necesitarás el patrón app factory: una función crear_app(servicio) en lugar de montar el servicio global al importar. Es un refactor de diez minutos que vuelve a demostrar la regla: probar mejora el diseño.
# tests/test_app.py
import pytest
from app import crear_app
@pytest.fixture
def client(servicio): # reutiliza la fixture de conftest
app = crear_app(servicio)
app.config["TESTING"] = True
return app.test_client()
def test_venta_sin_stock_devuelve_409(client):
respuesta = client.post("/api/ventas",
json={"titulo": "La Odisea", "unidades": 99})
assert respuesta.status_code == 409
assert "error" in respuesta.get_json() # el cuerpo explica el rechazo
def test_venta_valida_devuelve_201_con_importe(client):
respuesta = client.post("/api/ventas",
json={"titulo": "Fausto", "unidades": 1,
"socio": "LUIS-001", "cupon": "PAPYRUS10"})
assert respuesta.status_code == 201
assert respuesta.get_json()["importe"] == 18.67Itinerario B — Client de Django (10-05), donde TestCase te da una base de datos limpia por test (su equivalente del tmp_path):
# catalogo/tests.py
from django.test import TestCase
from catalogo.models import Libro
class TestVenta(TestCase):
def setUp(self):
Libro.objects.create(titulo="La Odisea", autor="Homero",
precio=12.50, stock=4)
def test_venta_sin_stock_muestra_error_en_el_form(self):
respuesta = self.client.post("/vender/",
{"titulo": "La Odisea", "unidades": 99})
self.assertEqual(respuesta.status_code, 200) # el form se re-muestra
self.assertContains(respuesta, "stock") # …con el error visibleFíjate en qué verifican estos tests: la traducción, no el negocio. Que el 409 sea 409, que el JSON traiga el importe, que el form re-muestre el error. El cálculo del 18.67 ya quedó demostrado dos niveles más abajo.
Regresiones: cada bug deja su test
Durante 12-03 encontraste bugs (todos los encontramos). La regla de 09-05, ahora como disciplina de proyecto: ningún bug se cierra sin su test. El flujo: bug detectado → escribes el test que lo reproduce (falla, rojo) → arreglas → el test pasa (verde) → el test se queda en la suite para siempre. Ese test vale más que diez tests inventados: protege un punto donde tu sistema demostró que sabía romperse. Anota en DECISIONES.md cada regresión con una línea ("cierre_de_caja sumaba strings — test_cierre_convierte_importes"): en 12-05, esa lista será oro para tu retrospectiva.
Depurar el sistema integrado
Cuando un test (o el uso real) falla en el sistema completo, el bug puede estar en cualquiera de las cuatro capas. Tres técnicas, por orden:
- Leer el traceback A TRAVÉS de las capas. Un traceback del sistema integrado es largo: líneas de Flask/Django, líneas tuyas de
app.py, deservicios.py, derepositorios.py. Recórrelo de abajo arriba y localiza la última línea tuya: esa es la capa donde estalló. Pero cuidado: donde estalla no siempre es donde se rompió — unKeyErroren el servicio puede ser un JSON mal guardado por el repositorio hace tres ventas. El traceback te da la capa de la explosión; la causa puede vivir una capa más abajo. - El log como testigo entre capas.
papyrus.logregistra qué hizo cada capa y en qué orden (RF7 ya no es un requisito burocrático: es tu caja negra). ¿El endpoint devolvió 500? Mira el log: si la última línea es elINFOde una venta, el fallo fue después de vender — en el guardado o en la serialización de la respuesta. Acabas de descartar dos capas sin abrir el depurador. pdben el punto de integración.breakpoint()justo donde una capa entrega a la siguiente (la llamada del endpoint al servicio, la del servicio al repositorio) e inspecciona el paquete que cruza la frontera:p datos,p type(fila["unidades"]). Como decía 12-03: la mayoría de los bugs de integración son "creía que me pasabas X y era Y" — y la frontera es donde se ve.
Checklist final de calidad
Antes de declarar el proyecto "entregable", pásale esta lista. No es burocracia: es la diferencia entre creer que está bien y saberlo.
- [ ] RF1: alta duplicada rechazada; baja + consulta → no encontrado. Test en verde.
- [ ] RF2: los 8 importes canónicos exactos (parametrizado); socio falso rechazado.
- [ ] RF3: venta sin stock no modifica nada (¡verifica el stock después del error!); 18.67 con socio+cupón; cupón falso rechazado.
- [ ] RF4: el test de integración pasa; matar el proceso durante un guardado no corrompe el JSON.
- [ ] RF5: todas las rutas/vistas con su test de código de estado.
- [ ] RF6: informe sobre
ventas_2026.csv→ 520 unidades, sábado 130, PNG generado. - [ ] RF7:
caplog(o el fichero) confirma elWARNINGen cada rechazo. - [ ] RNF1:
pytest→ todo verde, ~25 tests, ninguno tocandodatos/real. - [ ] RNF2: cero funciones públicas del paquete sin type hints.
- [ ] RNF3: cero
except Exceptiongenéricos sin justificación escrita. - [ ] RNF4: el README existe y otra persona podría arrancar el proyecto con él (se verifica en 12-05).
Errores Comunes y Consejos
- Probar el negocio por HTTP. Si
test_api_...verifica redondeos, tienes la pirámide invertida: lenta, frágil y con diagnósticos pobres. El endpoint prueba la traducción; el dominio, el fondo. - Tests que comparten estado. El síntoma clásico: pasan en solitario, fallan en suite (o según el orden). Causa: escriben en el mismo sitio. La fixture
rutascrea un mundo nuevo por test; úsala siempre. - Comparar floats a lo bravo.
assert importe == 18.67funciona aquí porque redondeamos a 2 decimales en un único punto (12-03). Si un assert de importes te falla por 0.0000001, no "arregles el test conpytest.approx": tienes un redondeo de más o de menos en el código.approxes para matemáticas continuas, no para dinero redondeado. - Borrar el test de un bug arreglado ("ya no falla, sobra"). Es exactamente al revés: es el único test con un fallo real demostrado en su historial.
- Depurar añadiendo
printpor todas partes. Ya tienes tres herramientas mejores y ordenadas: traceback (¿qué capa?), log (¿qué pasó antes?), pdb (¿qué cruza la frontera?). Losprintsueltos acaban olvidados en el código — el log es unprintcon contrato.
Ejercicios
- Hito de proyecto — la suite completa. Implementa las 12 filas de la suite mínima (con sus parametrizaciones). Verificación:
pytest -ven verde, y el nombre de cada test dice qué RF cubre sin abrir el fichero. - Hito de proyecto — el test del guardado atómico. Escribe
test_guardar_es_atomico: provoca un fallo a mitad deguardar(pista: pasa un catálogo con un objeto no serializable, que hará estallar ajson.dump) y comprueba que el fichero original sigue intacto y cargable. Este test justifica todo el diseño delos.replacede 12-03. - Hito de proyecto — tu regresión. Elige el bug más doloroso que encontraste en 12-03, escribe su test de regresión con un nombre que cuente la historia, y añade la línea correspondiente a
DECISIONES.md.
Soluciones
- Autocomprobación en lugar de solución: ejecuta
pytest --collect-onlyy compara la lista con la tabla de la suite mínima; cada fila debe tener al menos un test recogido. Sitest_informe_...tarda varios segundos, está bien: es el precio de pandas + PNG, y por eso hay uno, no veinte. - Orientativa:
catalogo_roto = dict(catalogo); catalogo_roto["X"] = object()ywith pytest.raises(TypeError): repo.guardar(catalogo_roto); después,repo.cargar()debe devolver el catálogo original sin error. Si tuguardarescribía directamente sobreself._ruta, este test te lo dirá con unJSONDecodeError— y acabas de entender, con un fallo controlado, para qué existía el fichero temporal. - Sin solución única. Criterio de calidad del nombre: alguien que no vivió el bug debe entender qué protege —
test_cierre_de_caja_convierte_importes_a_floatsí;test_bug_arreglado_2no.
Conclusión
Papyrus Online ya no es código que parece funcionar: es un sistema verificado. La pirámide dejó de ser un dibujo — dominio con tests unitarios a milisegundos, servicio sobre tmp_path, un test de integración que recorre vender → persistir → releer → cuadrar, y una interfaz cuya traducción HTTP está demostrada con su cliente de pruebas. Los criterios de 12-01 son ahora 25 tests con nombre, cada bug del desarrollo dejó su regresión, y sabes depurar a través de las capas con traceback, log y pdb en las fronteras. La checklist final está marcada: el proyecto está terminado. Pero terminado no es lo mismo que entregado: falta que otra persona pueda instalarlo, entenderlo y verlo funcionar — y falta que tú puedas contarlo. Documentación, demo, informe final y la mirada atrás sobre todo el camino: eso es la última lección del curso.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
