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

  1. La pirámide aplicada a Papyrus Online
  2. Datos de prueba vs datos reales: las fixtures canónicas
  3. La suite mínima exigible (criterios → tests)
  4. El test de integración del flujo completo
  5. Probar la interfaz: test client de Flask / Client de Django
  6. Regresiones: cada bug deja su test
  7. Depurar el sistema integrado
  8. 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.35

El 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.67

Itinerario 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 visible

Fí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, de servicios.py, de repositorios.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ó — un KeyError en 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.log registra 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 el INFO de 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.
  • pdb en 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 el WARNING en cada rechazo.
  • [ ] RNF1: pytest → todo verde, ~25 tests, ninguno tocando datos/ real.
  • [ ] RNF2: cero funciones públicas del paquete sin type hints.
  • [ ] RNF3: cero except Exception gené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 rutas crea un mundo nuevo por test; úsala siempre.
  • Comparar floats a lo bravo. assert importe == 18.67 funciona 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 con pytest.approx": tienes un redondeo de más o de menos en el código. approx es 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 print por todas partes. Ya tienes tres herramientas mejores y ordenadas: traceback (¿qué capa?), log (¿qué pasó antes?), pdb (¿qué cruza la frontera?). Los print sueltos acaban olvidados en el código — el log es un print con contrato.

Ejercicios

  1. Hito de proyecto — la suite completa. Implementa las 12 filas de la suite mínima (con sus parametrizaciones). Verificación: pytest -v en verde, y el nombre de cada test dice qué RF cubre sin abrir el fichero.
  2. Hito de proyecto — el test del guardado atómico. Escribe test_guardar_es_atomico: provoca un fallo a mitad de guardar (pista: pasa un catálogo con un objeto no serializable, que hará estallar a json.dump) y comprueba que el fichero original sigue intacto y cargable. Este test justifica todo el diseño del os.replace de 12-03.
  3. 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

  1. Autocomprobación en lugar de solución: ejecuta pytest --collect-only y compara la lista con la tabla de la suite mínima; cada fila debe tener al menos un test recogido. Si test_informe_... tarda varios segundos, está bien: es el precio de pandas + PNG, y por eso hay uno, no veinte.
  2. Orientativa: catalogo_roto = dict(catalogo); catalogo_roto["X"] = object() y with pytest.raises(TypeError): repo.guardar(catalogo_roto); después, repo.cargar() debe devolver el catálogo original sin error. Si tu guardar escribía directamente sobre self._ruta, este test te lo dirá con un JSONDecodeError — y acabas de entender, con un fallo controlado, para qué existía el fichero temporal.
  3. 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_float sí; test_bug_arreglado_2 no.

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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados