El módulo 8 terminó con una pregunta incómoda: cuando mañana cambies una línea de Papyrus — un descuento nuevo, un campo más en ventas.csv — ¿cómo garantizas que todo sigue funcionando? Los type hints de mypy verifican que los tipos encajan, pero no que precio_final() devuelva 12.35 € y no 12.36 €. Comprobarlo a mano cada vez no escala. La respuesta de la ingeniería de software es escribir código que comprueba el código: pruebas. En esta lección entenderás por qué son imprescindibles, qué anatomía tiene un test y qué merece probarse primero — antes de tocar ningún framework.
Contenido
- El coste de un bug: la mañana en que Ana rompió el precio de socio
- Qué es una prueba: el patrón AAA (preparar-actuar-comprobar)
asserta pelo: la primera herramienta y sus límites- El precursor artesanal:
if __name__ == "__main__" - Tipos de prueba y la pirámide
- Qué probar primero: casos felices, límites y errores esperados
- Cualidades de un buen test
El coste de un bug: la mañana en que Ana rompió el precio de socio
Un martes, Ana decide "ordenar un poco" el método precio_final() de modelos.py. El original calculaba todo en una expresión; ella lo separa en pasos y, de paso, redondea el resultado intermedio "para que quede más limpio":
# Versión original (correcta): redondea UNA vez, al final
def precio_final(self, socio: bool = False) -> float:
base = self.precio * (1 - DESCUENTO_SOCIO) if socio else self.precio
return round(base * (1 + IVA_LIBROS), 2)
# Versión "ordenada" de Ana (incorrecta): redondea DOS veces
def precio_final(self, socio: bool = False) -> float:
base = self.precio
if socio:
base = round(base * (1 - DESCUENTO_SOCIO), 2) # ← redondeo intermedio
return round(base * (1 + IVA_LIBROS), 2)Parece inofensivo. Pero para La Odisea (12.50 €), el precio de socio correcto es 12.50 × 0.95 × 1.04 = 12.35 €. Con el redondeo intermedio: 12.50 × 0.95 = 11.875 → round → 11.88 → × 1.04 = 12.3552 → 12.36 €. Un céntimo de más. Ana no lo nota: probó mentalmente con Hamlet, donde la doble redondez casualmente da el mismo resultado. Una semana después, la socia Júlia reclama, y el cierre_de_caja() lleva siete días sin cuadrar.
La lección no es "Ana es descuidada". Es que el coste de un bug crece brutalmente cuanto más tarde se detecta:
| Momento de detección | Coste típico | En Papyrus |
|---|---|---|
| Mientras escribes el código | Segundos | Ana ve el test en rojo y corrige antes de guardar |
| Antes de integrar (suite de tests) | Minutos | pytest falla en su máquina; nadie más lo sufre |
| En uso real (producción) | Horas o días + confianza | Júlia reclama, hay que revisar 7 cierres de caja y devolver céntimos |
Un test que hubiera fijado por escrito «el precio de socio de La Odisea es 12.35» habría detectado el bug en segundos. Eso es exactamente lo que vamos a aprender a escribir.
Qué es una prueba: el patrón AAA
Una prueba (test) es un fragmento de código que ejecuta otro fragmento de código con entradas conocidas y comprueba que el resultado coincide con lo esperado. Casi todos los tests del mundo siguen la misma estructura en tres pasos, llamada patrón AAA por sus siglas en inglés (Arrange, Act, Assert):
| Paso | En inglés | Qué haces | Ejemplo en Papyrus |
|---|---|---|---|
| 1. Preparar | Arrange | Montas el escenario: objetos, datos, ficheros | Crear Libro("La Odisea", 12.50, 4) |
| 2. Actuar | Act | Ejecutas la operación que quieres probar | Llamar a precio_final(socio=True) |
| 3. Comprobar | Assert | Verificas que el resultado es el esperado | ¿Devolvió 12.35? |
En código, con lo que ya sabes del curso:
from papyrus.modelos import Libro
# 1. Preparar: un libro con los datos canónicos del catálogo
libro = Libro("La Odisea", 12.50, 4)
# 2. Actuar: la operación bajo prueba
precio = libro.precio_final(socio=True)
# 3. Comprobar: assert falla con AssertionError si la condición es False
assert precio == 12.35, f"esperaba 12.35, obtuve {precio}"Fíjate en tres detalles:
- Cada test comprueba una cosa. Si este test falla, sabes exactamente qué se rompió: el precio de socio de La Odisea.
- El valor esperado está escrito a mano (
12.35), no calculado con la misma fórmula que el código probado. Si copiaras la fórmula, el test tendría el mismo bug que el código y nunca fallaría. - El mensaje del
assert(la f-string tras la coma) te dice qué pasó sin abrir el depurador.
assert a pelo: la primera herramienta y sus límites
La sentencia assert la conoces del __post_init__ de Libro y de validaciones puntuales: si la condición es falsa, lanza AssertionError. Con solo eso puedes escribir un script de comprobación:
# comprueba_papyrus.py — nuestra "suite" artesanal
from papyrus.modelos import Libro
from papyrus.almacen import vender, buscar_libro
from papyrus.errores import StockInsuficienteError
catalogo = {"Hamlet": Libro("Hamlet", 9.95, 6)}
# Caso feliz: 2 Hamlet con IVA son 20.70 € y el stock baja a 4
importe = vender(catalogo, "Hamlet", 2)
assert importe == 20.70, f"importe incorrecto: {importe}"
assert catalogo["Hamlet"].stock == 4, "el stock no se descontó"
# Error esperado: pedir más stock del disponible debe lanzar la excepción
try:
vender(catalogo, "Hamlet", 99)
assert False, "debería haber lanzado StockInsuficienteError"
except StockInsuficienteError:
pass # correcto: la excepción esperada llegó
# Título inexistente: buscar_libro devuelve None, no explota
assert buscar_libro(catalogo, "Rayuela") is None
print("Todas las comprobaciones pasaron.")Esto ya es probar, y es infinitamente mejor que nada. Pero tiene límites serios:
| Límite | Consecuencia |
|---|---|
| Se detiene en el primer fallo | Si fallan 5 cosas, solo ves 1; corriges, ejecutas, ves la siguiente... |
| No hay informe | Nadie te dice "12 pasaron, 2 fallaron, estas son"; solo un traceback o un print final |
python -O elimina los assert |
Con el flag de optimización, tu "suite" no comprueba nada y no avisa |
| El estado se comparte | El segundo bloque hereda el catálogo mutado por el primero: los tests se contaminan entre sí |
| Ejecutar es manual | Tienes que acordarte de lanzarlo, y de lanzarlo todo |
| Comprobar excepciones es torpe | El baile try/except/assert False es propenso a errores |
Los frameworks de testing (unittest en 09-02, pytest en 09-03) existen precisamente para resolver esta lista: descubren los tests, los ejecutan todos, los aíslan entre sí y producen un informe legible.
El precursor artesanal: if __name__ == "__main__"
En el módulo 3, cuando creamos papyrus_utils.py, añadimos al final del fichero un bloque if __name__ == "__main__": con una "mini-demo": unas llamadas de ejemplo que solo se ejecutaban al lanzar el módulo directamente, no al importarlo. Aquello era, sin saberlo, el ancestro de un test: código que ejercita el módulo con datos conocidos.
# Al final de papyrus_utils.py, allá por el módulo 3:
if __name__ == "__main__":
# mini-demo / mini-prueba manual
print(formatear_precio(12.5)) # ¿imprime "12.50 €"?La diferencia clave: aquella demo imprimía y un humano juzgaba con los ojos si el resultado era correcto. Un test afirma (assert) y es el propio programa quien juzga. Ese salto — de "mirar la salida" a "codificar la expectativa" — es toda la esencia del testing. Todo lo demás (frameworks, fixtures, TDD) es maquinaria alrededor de esa idea.
Tipos de prueba y la pirámide
No todas las pruebas operan al mismo nivel. La clasificación clásica distingue tres:
| Tipo | Qué comprueba | Ejemplo en Papyrus | Velocidad | Cantidad recomendada |
|---|---|---|---|---|
| Unitaria | Una pieza pequeña y aislada (una función, un método) | precio_final(socio=True) devuelve 12.35 |
Milisegundos | Muchas |
| Integración | Que varias piezas colaboran bien juntas | cierre_de_caja() lee un ventas.csv real de prueba y suma bien |
Décimas de segundo | Algunas |
| End-to-end (E2E) | El sistema completo, como lo usaría una persona | Simular la jornada entera: cargar catálogo, vender, reponer, cerrar caja | Segundos o más | Pocas |
La proporción recomendada se dibuja como una pirámide: base ancha de tests unitarios (rápidos y baratos), capa media de integración, y una punta fina de E2E (lentos, frágiles, caros de mantener):
graph TD
E2E["End-to-end: pocas<br/>(lentas, frágiles, caras)"] --> INT["Integración: algunas<br/>(módulos colaborando)"]
INT --> UNIT["Unitarias: muchas<br/>(rápidas, aisladas, baratas)"]
En este módulo nos centraremos en la base de la pirámide — las unitarias — con alguna incursión en integración (probar cierre_de_caja contra un CSV de prueba). Las pruebas E2E de una aplicación web las verás de refilón cuando Papyrus llegue a la web en el módulo 10.
Qué probar primero: casos felices, límites y errores esperados
Ante una función, ¿qué casos merecen test? Hay tres familias, por orden de prioridad:
- El caso feliz (happy path): la función usada como se espera, con datos normales.
- Los límites: los bordes donde los bugs anidan — cero, uno, exactamente-el-máximo, vacío.
- Los errores esperados: las entradas inválidas deben fallar como está documentado (¡las excepciones del módulo 7 son parte del contrato!).
Apliquémoslo a las dos funciones estrella de Papyrus. Para Libro.precio_final:
| Familia | Caso | Esperado |
|---|---|---|
| Feliz | La Odisea sin ser socio | 12.50 × 1.04 = 13.00 |
| Feliz | La Odisea siendo socio | 12.35 |
| Límite | Redondeo a 2 decimales | Hamlet socio → 9.83 (no 9.8306) |
| Error | Libro("X", -5.0) |
__post_init__ lanza ValueError |
Y para vender(catalogo, titulo, unidades), que tiene un contrato más rico (lo blindamos en el módulo 7):
| Familia | Caso | Esperado |
|---|---|---|
| Feliz | Vender 2 Hamlet | Devuelve 20.70 y el stock pasa de 6 a 4 |
| Límite | Vender exactamente todo el stock (4 Odiseas) | Funciona; el stock queda a 0 |
| Error | Vender 5 Odiseas (solo hay 4) | StockInsuficienteError y el catálogo queda intacto |
| Error | Título inexistente | LibroNoEncontradoError |
| Error | unidades=0 o negativas |
ValueError |
| Error | unidades="dos" |
TypeError |
Observa la fila de StockInsuficienteError: el test no solo comprueba que la excepción salta, sino que no hubo efectos secundarios (el stock no cambió). Recuerda de 08-04 que vender muta el catálogo al final precisamente para garantizar esto — y un test es la forma de que esa garantía no se pierda en un refactor futuro.
Cualidades de un buen test
No cualquier código con assert es un buen test. Los buenos comparten cuatro cualidades:
- Rápido: la suite entera debe correr en segundos. Si tarda, dejarás de ejecutarla, y un test que no se ejecuta no protege nada.
- Aislado: cada test monta su propio escenario y no depende de otros tests ni del orden de ejecución. El script artesanal de antes violaba esto: el segundo bloque heredaba el catálogo mutado por el primero.
- Repetible: mismo resultado hoy, mañana y en el portátil de Luis. Nada de depender de la hora actual, de la red o del
datos/catalogo.csvreal (en 09-03 verástmp_pathpara esto). - Legible: un test es documentación ejecutable.
test_vender_sin_stock_lanza_error_y_no_muta_catalogocuenta el contrato mejor que un párrafo de comentarios.
Errores Comunes y Consejos
- Probar solo el caso feliz. Los bugs viven en los bordes: stock 0, listas vacías, títulos inexistentes. Si tus tests solo prueban lo normal, protegen poco.
- Calcular el valor esperado con la misma fórmula que el código probado.
assert precio == round(12.50 * 0.95 * 1.04, 2)repetiría el bug si la fórmula está mal. Escribe el número literal:12.35. - Comparar floats con
==alegremente. Aquí funciona porqueprecio_finalredondea a 2 decimales, pero0.1 + 0.2 == 0.3esFalse(lo viste en M1). En 09-02 y 09-03 conocerásassertAlmostEqualypytest.approx. - Tests que dependen de datos reales. Si tu test lee
datos/catalogo.csv, fallará el día que Ana añada un libro. Crea los datos dentro del test. - Confiar en
assertpara validaciones de producción. Recuerda:python -Olos elimina. Para validar entradas de usuario, lanza excepciones (ValueError); reservaassertpara tests e invariantes internas. - Consejo: cuando encuentres un bug a mano, escribe primero el test que lo reproduce y luego corrígelo. Ese test de regresión evitará que el bug vuelva (lo sistematizaremos en 09-05).
Ejercicios
Ejercicio 1
Escribe, con assert a pelo y siguiendo el patrón AAA (comenta cada paso), tres comprobaciones para Libro.precio_final: (a) El Quijote sin socio, (b) El Quijote con socio, (c) Fausto con socio. Calcula a mano los valores esperados con las constantes canónicas (DESCUENTO_SOCIO=0.05, IVA_LIBROS=0.04, redondeo a 2 decimales).
Ejercicio 2
Sin escribir código: diseña en una tabla (familia / caso / resultado esperado) los casos de prueba de reponer(catalogo, titulo, unidades) -> None, siguiendo el modelo de la tabla de vender. Incluye al menos un caso feliz, un límite y dos errores esperados.
Ejercicio 3
Clasifica cada escenario como prueba unitaria, de integración o end-to-end: (a) hay_stock() devuelve False cuando stock == 0; (b) cierre_de_caja() suma correctamente un ventas.csv de tres filas creado para la prueba; (c) simular una jornada completa de Papyrus: cargar catálogo desde fichero, vender 3 libros, reponer 2 y cerrar caja comprobando el total; (d) StockInsuficienteError guarda titulo, solicitado y disponible en sus atributos.
Soluciones
Ejercicio 1
from papyrus.modelos import Libro
# (a) El Quijote sin socio: 15.90 × 1.04 = 16.536 → 16.54
libro = Libro("El Quijote", 15.90, 8) # Preparar
precio = libro.precio_final() # Actuar
assert precio == 16.54, f"esperaba 16.54, obtuve {precio}" # Comprobar
# (b) El Quijote con socio: 15.90 × 0.95 × 1.04 = 15.7092 → 15.71
precio = libro.precio_final(socio=True)
assert precio == 15.71, f"esperaba 15.71, obtuve {precio}"
# (c) Fausto con socio: 21.00 × 0.95 × 1.04 = 20.748 → 20.75
fausto = Libro("Fausto", 21.00, 10)
precio = fausto.precio_final(socio=True)
assert precio == 20.75, f"esperaba 20.75, obtuve {precio}"Nota cómo cada valor esperado es un literal calculado a mano, no la fórmula.
Ejercicio 2
| Familia | Caso | Resultado esperado |
|---|---|---|
| Feliz | Reponer 5 Hamlet (stock 6) | Devuelve None; el stock pasa a 11 |
| Límite | Reponer 1 unidad | Stock 6 → 7 (el mínimo válido funciona) |
| Error | Título inexistente | LibroNoEncontradoError y catálogo intacto |
| Error | unidades=0 o negativas |
ValueError; el stock no cambia |
| Error | unidades=2.5 o "tres" |
TypeError; el stock no cambia |
Ejercicio 3: (a) unitaria — un método aislado; (b) integración — colaboran el lector de CSV, el parseo y la suma contra un fichero real (de prueba); (c) end-to-end — recorre el sistema completo como lo haría Ana; (d) unitaria — comprueba una clase de excepción aislada.
Conclusión
Ya tienes el mapa: un test es código que fija por escrito lo que el programa debe hacer — preparar, actuar, comprobar — y el bug de Ana demostró que detectar un error en segundos cuesta infinitamente menos que detectarlo cuando Júlia reclama. Sabes que assert a pelo funciona pero no escala (se para en el primer fallo, no informa, no aísla), que la pirámide manda muchas pruebas unitarias y pocas E2E, y que hay que cubrir casos felices, límites y errores esperados con tests rápidos, aislados, repetibles y legibles. El if __name__ == "__main__" del módulo 3 fue nuestro precursor artesanal; ahora toca la herramienta industrial. En la próxima lección, la biblioteca estándar nos da el primer framework completo: unittest, con clases de test, aserciones especializadas (incluida una para los floats de nuestros precios) y un ejecutor que descubre y corre toda la suite con un solo comando.
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
