AulaML Python · C++
Python intermedio · 40 min · Python avanzado

Testing, logging y debugging

Comprobar que el código hace lo que dices, dejar rastro de lo que pasa y encontrar el fallo sin adivinar

Escribir código que funciona una vez no basta: necesitas pruebas que lo demuestren, registros que expliquen qué pasó en producción y una forma de inspeccionar el estado cuando algo falla. Aquí aprenderás assert, pytest, logging y pdb con criterio para elegir cada herramienta.

Lección 5 de 6 del módulo
Al terminar esta lección podrás
  • Distinguir un error de sintaxis, una excepción y un fallo lógico
  • Escribir pruebas con assert y organizarlas con pytest, fixtures y parametrización
  • Interpretar la cobertura sin confundirla con calidad
  • Configurar logging con niveles, formato y handlers adecuados
  • Depurar con breakpoint y pdb en lugar de sembrar prints

Antes necesitas: Errores y excepciones · Módulos, paquetes y entornos virtuales

En esta lección
  1. ¿Qué es una prueba y qué es un registro?
  2. ¿Para qué sirve todo esto?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis: assert, pytest y logging
  5. Ejemplo básico: comprobar con assert
  6. Ejemplo intermedio: una prueba de pytest con fixture y parámetros
  7. Caso práctico: instrumentar un proceso ETL
  8. Visualización: qué mensajes pasan el filtro
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

Hay una etapa en la que tu programa funciona y aun así no sabes si seguirá funcionando mañana. Cambias una función para arreglar un caso, rompes otro sin darte cuenta, y lo descubres cuando el informe ya se envió. Las tres herramientas de esta lección existen para que eso deje de ser una sorpresa: pruebas para comprobar, registros para observar, y un depurador para mirar dentro cuando algo no cuadra.

Antes de entrar, conviene ordenar el vocabulario, porque se usa mal. Un error de sintaxis impide que el archivo se ejecute. Una excepción ocurre durante la ejecución y, si no la atrapas, termina el programa. Un fallo lógico es el peor: el programa termina sin errores y devuelve un resultado incorrecto.

TipoCuándo apareceEjemplo
Error de sintaxisAntes de ejecutar: el archivo no compilaif x = 3: → SyntaxError
ExcepciónDurante la ejecución, en una línea concreta10 / 0 → ZeroDivisionError
Fallo lógicoNunca avisa; el resultado es incorrectoDividir entre len(notas) + 1

¿Qué es una prueba y qué es un registro?

Una prueba es código que ejecuta tu código y comprueba un resultado esperado. Si el resultado no coincide, falla de forma visible y con la línea exacta. En Python la forma mínima es assert condicion, que lanza AssertionError cuando la condición es falsa.

Un registro (log) es una línea de texto que tu programa emite para contar lo que hace: cuántas filas leyó, qué usuario pidió el informe, qué error se capturó. Se diferencia de print en que tiene nivel, origen y formato configurables, y en que puede apagarse sin borrar código.

El debugging es la tercera pata: inspeccionar el estado de un programa detenido. print sirve para una duda puntual; un depurador te deja parar en una línea, ver todas las variables y avanzar paso a paso sin modificar el archivo.

¿Para qué sirve todo esto?

  • Detectar regresiones: al cambiar una función, las pruebas avisan si rompiste un comportamiento anterior.
  • Documentar intención: un test que dice aplicar_descuento(100, 0.10) == 90.0 explica la regla mejor que un comentario.
  • Diagnosticar en producción: los logs muestran la secuencia de hechos cuando no puedes reproducir el fallo en tu máquina.
  • Medir la confianza: la cobertura indica qué líneas nunca se ejecutaron en las pruebas, no si las que sí corren están bien.
  • Ahorrar tiempo: un breakpoint() bien puesto encuentra en un minuto lo que veinte print no encuentran en una hora.

¿Cómo funciona por dentro?

assert es una instrucción, no una función: evalúa la expresión y, si es falsa, lanza AssertionError con el mensaje opcional. Python puede eliminar todos los assert cuando se ejecuta con python3 -O (modo optimizado). Por eso un assert nunca debe validar datos que vienen del usuario ni sustituir a un if ... raise.

pytest descubre pruebas por convención: archivos test_*.py o *_test.py, funciones que empiezan por test_, clases que empiezan por Test. No hace falta heredar de nada ni registrar cada test. Además reescribe los assert para que un fallo muestre los valores intermedios, y ofrece fixtures (datos preparados y reutilizables) y parametrize (el mismo test con varias entradas).

El módulo logging organiza el trabajo en tres piezas: un logger que recibe los mensajes, uno o varios handlers que deciden a dónde van (consola, archivo), y un formatter que define el texto. Los mensajes suben por la jerarquía de loggers hasta el logger raíz, que es el configurado por basicConfig. Cada mensaje tiene un nivel numérico y solo se emite si alcanza el nivel configurado.

NivelValorCuándo usarlo
DEBUG10Detalle que solo necesitas al investigar un fallo
INFO20Hitos normales: inicio, filas leídas, fin correcto
WARNING30Algo raro que no impide continuar
ERROR40Una operación falló y no se completó
CRITICAL50El proceso no puede seguir funcionando

Sintaxis: assert, pytest y logging

PYTHON Las tres herramientas en su forma mínima
# 1. Comprobación simple
assert 2 + 2 == 4
assert 10 > 3, "10 debería ser mayor que 3"

# 2. Prueba de pytest (archivo test_suma.py)
# def test_suma():
#     assert sumar(2, 3) == 5

# 3. Registro con nivel y formato
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger(__name__)
log.info("proceso iniciado")

El mensaje de un assert se escribe después de una coma y solo se evalúa si la condición falla. En pytest, cada archivo de prueba importa lo que va a probar; no hay un main ni una lista de tests que mantener. En logging, getLogger(__name__) da al logger el nombre del módulo, así el formato puede decir de dónde viene cada línea.

Ejemplo básico: comprobar con assert

Antes de instalar nada puedes verificar una función con asserts. Este ejemplo comprueba el caso normal y el caso de una sola nota, y además define qué debe ocurrir con una lista vacía.

PYTHON promedio.py
def promedio(notas):
    if not notas:
        raise ValueError("la lista de notas está vacía")
    return sum(notas) / len(notas)

assert promedio([10, 20]) == 15.0
assert promedio([20]) == 20.0
print("todas las comprobaciones pasan")

Si cambias el 20 por 30 en la segunda comprobación, el programa se detiene con AssertionError y la línea exacta. Ese fallo temprano es la virtud del assert: el error aparece donde se produce, no tres pasos después cuando el resultado ya se guardó. La validación de la lista vacía, en cambio, es un raise de verdad: seguiría activa con -O.

Ejemplo intermedio: una prueba de pytest con fixture y parámetros

Este es el aspecto de un proyecto real. precios.py contiene la lógica; test_precios.py contiene las pruebas. Las fixtures preparan datos compartidos y parametrize repite el mismo test con tres entradas sin copiar y pegar. Instala pytest dentro del entorno virtual con pip install pytest.

PYTHON precios.py
def aplicar_descuento(precio: float, descuento: float) -> float:
    if not 0 <= descuento < 1:
        raise ValueError("el descuento debe estar entre 0 y 1")
    return precio * (1 - descuento)
PYTHON test_precios.py
import pytest

from precios import aplicar_descuento


@pytest.fixture
def catalogo():
    return {"teclado": 120.0, "mouse": 45.0}


@pytest.mark.parametrize(
    "precio, esperado",
    [(100.0, 90.0), (45.0, 40.5), (0.0, 0.0)],
)
def test_descuento_diez_por_ciento(precio, esperado):
    assert aplicar_descuento(precio, 0.10) == pytest.approx(esperado)


def test_catalogo_tiene_dos_productos(catalogo):
    assert len(catalogo) == 2


def test_descuento_invalido():
    with pytest.raises(ValueError):
        aplicar_descuento(100.0, 1.5)

Con pytest -q obtienes 5 passed: tres casos del test parametrizado, el del catálogo y el del error esperado. pytest.approx compara flotantes con tolerancia, porque operaciones como 0.1 + 0.2 no dan exactamente 0.3 en binario. La fixture catalogo se inyecta por el nombre del parámetro, y pytest.raises comprueba que el error se lanza en lugar de dejar que tumbe la suite.

Caso práctico: instrumentar un proceso ETL

En un proceso que carga, limpia y transforma datos, los logs son la única evidencia que tendrás a las tres de la mañana. La clave es registrar hechos con nivel adecuado y no convertir el registro en una novela: una línea por hito y una por anomalía.

PYTHON etl.py
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("etl")


def cargar(filas):
    if not filas:
        raise ValueError("no hay filas para procesar")
    log.info("cargadas %d filas", len(filas))
    return filas


def limpiar(filas):
    validas = [fila for fila in filas if fila.get("precio")]
    descartadas = len(filas) - len(validas)
    if descartadas:
        log.warning("filas descartadas: %d", descartadas)
    return validas


try:
    datos = cargar([{"precio": 10}, {"precio": None}, {"precio": 25}])
    print(limpiar(datos))
except ValueError as error:
    log.error("ETL abortado: %s", error)

La salida es INFO etl: cargadas 3 filas, WARNING etl: filas descartadas: 1 y la lista con dos diccionarios. Fíjate en los %d y %s: pasar los valores como argumentos retrasa el formateo hasta que el mensaje se emite, y evita construir cadenas que luego se descartan por nivel. Si mañana bajas el nivel a DEBUG, no cambias ni una línea del código.

Visualización: qué mensajes pasan el filtro

Recorre los pasos y observa el filtro de nivel. Los mensajes DEBUG e INFO se descartan antes de formatearse; solo WARNING y ERROR llegan al handler de la consola.

Ejecución visual ·Configurar WARNING y emitir cuatro mensajes
1 / 7
1 import logging
2
3 logging.basicConfig(level=logging.WARNING, format="%(levelname)s:%(name)s:%(message)s")
4 log = logging.getLogger("ventas")
5
6 log.debug("cargando el CSV")
7 log.info("12 filas leídas")
8 log.warning("3 filas sin precio")
9 log.error("no se pudo guardar el informe")
Se importa el módulo logging, que ya viene con Python.
Variables
logging = <module 'logging'>
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

SíntomaPor qué ocurreCómo se arregla
AssertionError sin pistaEl assert no lleva mensajeAñade assert condicion, f"valor: {x}"
pytest ejecuta 0 testsEl archivo no se llama test_*.py o la función no empieza por test_Renombra el archivo y la función según la convención
Los log.info no aparecenEl nivel por defecto es WARNINGConfigura level=logging.INFO al inicio
RuntimeWarning: coroutine ... was never awaitedLlamaste a una función async sin awaitAñade await o ejecútala con asyncio.run
El programa se detiene en (Pdb)Dejaste un breakpoint() olvidadoEscribe c para continuar o elimina la línea
El assert que nunca falla
Incorrecto
PYTHON Una tupla siempre es verdadera
x = 3
assert (x == 4, "x debería ser 4")
print("sigue")
Correcto
PYTHON Condición y mensaje separados por coma
x = 3
assert x == 4, "x debería ser 4"
print("no se alcanza")

Con paréntesis, el assert evalúa la tupla `(False, "x debería ser 4")`, y una tupla no vacía siempre es verdadera. La coma debe ir fuera de la condición para que el segundo elemento sea el mensaje.

SHELL Una sesión de pdb
python3 -m pdb factura.py
# (Pdb) b factura.py:8     ← punto de parada en la línea 8
# (Pdb) c                  ← continúa hasta allí
# (Pdb) p cantidad         ← muestra el valor actual
# (Pdb) n                  ← ejecuta la línea siguiente
# (Pdb) q                  ← termina la sesión

Buenas prácticas

  • Prueba el caso normal, el límite y el error esperado; los tres suelen revelar fallos distintos.
  • Un test debe poder ejecutarse solo y en cualquier orden: no dependas del test anterior.
  • Nombra los tests por el comportamiento (test_descuento_invalido_lanza_error), no por el método.
  • No uses assert para validar entradas de usuario: con -O desaparecen. Usa if ... raise ValueError.
  • Registra hechos, no emociones: filas descartadas: 3 sirve; algo va mal no.
  • Usa parámetros %s/%d en logging y deja DEBUG para el detalle que solo activas al investigar.
  • Ejecuta pytest --cov=paquete para ver qué quedó sin probar, y revisa las líneas, no el porcentaje.

Ejercicios

Ejercicio 1

Probar un límite con parametrización

Escribe test_edad.py para una función es_mayor_de_edad(edad) que devuelve True desde los 18 años. Cubre el límite con parametrize (17, 18 y 65) y añade una prueba de que un texto lanza TypeError. Ejecuta pytest -q test_edad.py y comprueba que pasan los cuatro casos.
Pista

Usa pytest.mark.parametrize("edad, esperado", [...]) y pytest.raises(TypeError) para el caso inválido. Compara con is esperado para exigir un booleano real.

Ejercicio 2

Registrar un cálculo con errores controlados

Crea factura.py con total_factura(cantidad, precio). Si cantidad es menor o igual que cero, lanza ValueError con un mensaje que incluya el valor. Si es válida, registra el total con log.info y devuélvelo redondeado a dos decimales. Envolvé las llamadas en un try/except que registre el error con log.error y no detenga el programa.
Pista

Configura logging.basicConfig(level=logging.INFO, ...) antes de crear el logger. Redondea con round(...) para no arrastrar los decimales binarios del float.

Completar código Completa la configuración de logging
import logging

logging.basicConfig(level=logging., format="%(levelname)s %(message)s")
log = logging.getLogger()

log.("inicio del proceso")

El nivel que muestra los hitos normales es `INFO`. El nombre del módulo actual está en `__name__`.

Soluciones explicadas

En el primer ejercicio, la decisión importante es probar el límite: 17 y 18 son los valores que separan los dos comportamientos, y un test con solo 20 no habría detectado un > escrito por error en lugar de >=. Añadir 65 comprueba que el caso normal sigue funcionando. La prueba del tipo incorrecto no busca que el programa sea elegante, sino fijar por escrito qué ocurre con una entrada que no es un número.

En el segundo, el redondeo no es un detalle cosmético: 3 * 19.9 da 59.699999999999996 en coma flotante, y un total de factura con esa cola es inaceptable. round(..., 2) lo resuelve para el valor devuelto, y %.2f hace lo mismo en el log. También es deliberado que el except esté fuera de la función: la función informa del problema lanzando, y la capa que decide continuar o abortar es quien llama.

La regla que une ambos ejercicios: una prueba comprueba comportamiento observable, un log registra lo que ya ocurrió y un depurador examina lo que está ocurriendo. Mezclarlos produce código difícil de probar: si la función imprime en vez de devolver, el test tiene que capturar la salida; si el log reemplaza al error, nadie se entera del fallo.

Resumen

  • Un error de sintaxis impide ejecutar; una excepción ocurre en ejecución; un fallo lógico produce un resultado incorrecto sin avisar.
  • assert comprueba una condición y lanza AssertionError; con -O se elimina, así que no valida entradas reales.
  • pytest descubre test_*.py y funciones test_*, y reescribe los assert para mostrar los valores del fallo.
  • Las fixtures preparan datos reutilizables y parametrize ejecuta el mismo test con varias entradas.
  • La cobertura mide líneas ejecutadas, no comportamientos comprobados.
  • logging filtra por nivel: DEBUG 10, INFO 20, WARNING 30, ERROR 40, CRITICAL 50.
  • breakpoint() abre pdb: p inspecciona, n avanza, c continúa y q sale.

Evaluación

Evaluación 1

Ejecutas un script con python3 -O que contiene assert cantidad > 0. ¿Qué ocurre si cantidad vale 0?

Evaluación 2

Creas pruebas_precio.py con una función def probar_precio(): y ejecutas pytest. ¿Qué ocurre?

Evaluación 3

Un guardado de informe falla y el proceso puede continuar con el siguiente informe. ¿Qué nivel de log es el más adecuado?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.