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

  1. El coste de un bug: la mañana en que Ana rompió el precio de socio
  2. Qué es una prueba: el patrón AAA (preparar-actuar-comprobar)
  3. assert a pelo: la primera herramienta y sus límites
  4. El precursor artesanal: if __name__ == "__main__"
  5. Tipos de prueba y la pirámide
  6. Qué probar primero: casos felices, límites y errores esperados
  7. 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:

  1. El caso feliz (happy path): la función usada como se espera, con datos normales.
  2. Los límites: los bordes donde los bugs anidan — cero, uno, exactamente-el-máximo, vacío.
  3. 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.csv real (en 09-03 verás tmp_path para esto).
  • Legible: un test es documentación ejecutable. test_vender_sin_stock_lanza_error_y_no_muta_catalogo cuenta 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 porque precio_final redondea a 2 decimales, pero 0.1 + 0.2 == 0.3 es False (lo viste en M1). En 09-02 y 09-03 conocerás assertAlmostEqual y pytest.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 assert para validaciones de producción. Recuerda: python -O los elimina. Para validar entradas de usuario, lanza excepciones (ValueError); reserva assert para 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

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