3. Testing desde el principio y manejo de errores

Metemos el testing pronto, casi al principio y no al final, a propósito: queremos que acompañe al resto del curso. Cuando pruebas lo que escribes, escribes mejor.

Primer test

Por qué se prueba el código. Dos razones que valen su peso en oro:

  1. Refactorizar sin miedo. Con tests, cambiar el código por dentro deja de dar vértigo: si rompes algo, un test se pone rojo y te avisa al instante.
  2. Documentar el comportamiento. Un test es un ejemplo ejecutable de cómo se usa tu código y qué esperas de él. No se queda anticuado como un comentario, porque si mientes, falla.

Aunque parezca que testear es perder el tiempo, en realidad economiza cada línea de código. Al principio del proyecto la productividad es baja, pero a medida que crece te ahorra las horas que ibas a gastar cazando errores. Antes de decirte "no hago testing porque es demasiado pequeño", recuerda que todo proyecto empieza siendo pequeño. Después será demasiado grande para testear con ganas.

Nuestra primera prueba con pytest. Instalamos la herramienta:

uv add --dev pytest

El flag --dev indica que pytest es una dependencia de desarrollo: la necesitas para trabajar en el proyecto, pero no forma parte del producto que entregas.

Y escribimos un test para nuestra función average_wave_height, en tests/test_waves.py:

from metocean_tools.waves import average_wave_height


def test_average_wave_height():
    # Given
    wave_heights_m = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]

    # When
    result = average_wave_height(wave_heights_m)

    # Then
    assert result == 3.5

Lo ejecutamos:

uv run pytest

Y pytest recorre la carpeta, encuentra el test, lo ejecuta y te dice si pasa. Si pasa, respiras tranquilo. Si falla, te enseña exactamente qué esperabas y qué obtuviste.

La estructura de un test: Given-When-Then. Habrás visto los comentarios # Given, # When, # Then. No son decoración, son un patrón que ordena el test en tres bloques:

  1. Given (dado): preparas el escenario, los datos de entrada, las condiciones.
  2. When (cuando): ejecutas el código que quieres probar.
  3. Then (entonces): compruebas que el resultado es el esperado.

También se conoce como Arrange, Act, Assert (preparar, actuar, comprobar). Son la misma idea. Divide siempre tus tests así y se leerán solos.

Nombres de tests que describen el caso. El nombre de un test es la descripción de lo que verifica. test_average_wave_height_with_empty_list te dice al instante qué falló cuando se pone rojo. test_1 no te dice nada.

Qué es una aserción. assert es la comprobación: le sigue una expresión que debe ser verdadera. Si lo es, el test continúa. Si no, pytest para y te muestra el desajuste con todo detalle: qué valor esperabas y cuál obtuviste. Aprender a leer ese informe de fallo es media batalla.

Dónde viven los tests y cómo se nombran. Los ficheros de test empiezan por test_ (o terminan en _test.py), y viven en la carpeta tests/. Las funciones de test empiezan también por test_. pytest los encuentra por esa convención, sin que tengas que registrarlos en ningún sitio.

Manejo de errores

Un programa profesional no es el que nunca falla, es el que falla bien: dice qué pasó, dónde y por qué, en lugar de reventar con un mensaje críptico o, peor, seguir adelante con datos corruptos.

Excepciones: qué son y cuándo capturarlas. Una excepción es la forma de Python de decir "aquí ha pasado algo que no puedo manejar por mi cuenta". La regla mental: captura una excepción solo si puedes hacer algo útil con ella. Si no, déjala subir, que llegue a alguien que sí sepa qué hacer.

No silenciar errores. El peor antipatrón del manejo de errores es este:

# NUNCA hagas esto
try:
    process_sensor_data(raw)
except Exception:
    pass

Ese except vacío se traga cualquier problema y sigue como si nada. Es cavar un agujero y taparlo con una alfombra: el día que algo falle de verdad, no tendrás ni idea de dónde. Si capturas, haz algo (registra, avisa, devuelve un valor de reserva), pero no calles.

Crear excepciones propias con significado. Cuando algo falla en tu dominio, di qué falló con una excepción con nombre:

class SensorReadError(Exception):
    """Se lanza cuando una lectura de sensor no se puede interpretar."""

Compara raise SensorReadError("negative wave height: -3.2 m") con un ValueError genérico. La primera te dice exactamente qué pasó y la puedes capturar de forma selectiva más arriba, sin atrapar de paso errores que no tienen nada que ver.

Ahora podemos cerrar el cabo suelto de la lección anterior, la lista vacía:

class SensorReadError(Exception):
    """Se lanza cuando una lectura de sensor no se puede interpretar."""


def average_wave_height(wave_heights_m: list[float]) -> float:
    """Calcula la altura media de ola de una serie de lecturas.

    Args:
        wave_heights_m: Alturas de ola, en metros.

    Returns:
        La altura media, en metros.

    Raises:
        SensorReadError: Si la serie está vacía.
    """
    if not wave_heights_m:
        raise SensorReadError("no wave readings to average")
    return sum(wave_heights_m) / len(wave_heights_m)

Validación de datos de entrada. Norma de oro: nunca confíes en un fichero externo. Un fichero de sensores puede venir con columnas de más, valores imposibles o tipos equivocados. Validar a mano con if por todas partes es tedioso y se te escapan casos. Para eso hay librerías como msgspec (moderna, rápida, escrita en C) o pydantic:

import msgspec


class SensorReading(msgspec.Struct, strict=True):
    """Lectura validada de un sensor de la boya."""

    timestamp: str
    wave_height_m: float
    wind_speed_kn: float


raw = {"timestamp": "2026-01-15T10:00:00Z", "wave_height_m": 3.2, "wind_speed_kn": 18.0}
reading = msgspec.convert(raw, type=SensorReading)

Con strict=True, si un tipo no encaja exactamente, salta un error. Validas en la frontera del programa, donde entran los datos, y a partir de ahí trabajas tranquilo sabiendo que son correctos.

Registrar y representar datos con dataclasses y Enum. Para modelar datos usamos registros, no jerarquías de objetos. Un dataclass para agrupar campos y un Enum para un conjunto cerrado de valores:

from dataclasses import dataclass
from enum import Enum


class SeaCondition(Enum):
    CALM = "calm"
    MODERATE = "moderate"
    ROUGH = "rough"


@dataclass
class OperationalWindow:
    """Ventana de trabajo evaluada a partir del estado de la mar."""

    wave_height_m: float
    condition: SeaCondition
    is_operational: bool

El Enum evita que alguien escriba "clam" en lugar de "calm" y no se entere hasta que sea tarde.

Depurar sin print

Cuando algo falla, la reacción instintiva es sembrar el código de print para ver por dónde va. Funciona, sí, pero ensucia el código y luego tienes que acordarte de borrarlo todo. Hay una forma mejor, estándar y sin instalar nada.

breakpoint(). Disponible desde Python 3.7. Escribe breakpoint() en la línea donde quieres parar y ejecuta el programa normal. Cuando llegue ahí, se detiene y te abre una consola interactiva (Pdb) con el estado congelado en ese punto:

def average_wave_height(wave_heights_m: list[float]) -> float:
    total = sum(wave_heights_m)
    breakpoint()  # La ejecución se detiene aquí
    return total / len(wave_heights_m)

Desde el (Pdb) puedes inspeccionar variables, modificarlas en caliente y ejecutar cualquier expresión de Python. Es como abrir el capó con el motor en marcha.

Comandos básicos:

Comando Acción
n (next) ejecuta la línea actual sin entrar en funciones
s (step) igual, pero entra en la función llamada
c (continue) sigue hasta el próximo breakpoint o el final
l (list) muestra el código alrededor de la línea actual
p expr imprime una variable o expresión: p total
pp expr igual pero bonito, útil para diccionarios y listas
w (where) muestra la pila de llamadas
a (args) muestra los argumentos de la función actual
q (quit) aborta la ejecución

Ignorar todos los breakpoints sin borrarlos. Cuando terminas de depurar pero aún no has quitado los breakpoint(), ejecuta con esta variable de entorno y no parará en ninguno:

PYTHONBREAKPOINT=0 uv run demo.py

Modo post-mortem. Si un script revienta y no tiene breakpoint(), puedes pedirle a Python que te deje justo en el punto del fallo para inspeccionar el estado:

uv run python -m pdb -c continue demo.py

Todo esto se conecta con el depurador gráfico de VSCode que configuramos en la lección de puesta a punto del entorno: es la misma idea con botones en lugar de comandos. Usa el que más cómodo te resulte, pero deja el print para imprimir resultados, no para depurar.

Fixtures, parametrización y cobertura

Ya sabes escribir un test. Ahora vamos a escribir muchos sin repetirte.

Fixtures: reutilizar datos y objetos de prueba. Si varios tests necesitan el mismo objeto o los mismos datos, no los crees una y otra vez. Una fixture es una función decorada con @pytest.fixture que prepara algo y se lo pasa a cualquier test que lo pida por su nombre:

import pytest

from metocean_tools.waves import average_wave_height


@pytest.fixture
def calm_sea_waves():
    """Serie de olas de mar en calma, en metros."""
    return [0.3, 0.4, 0.5, 0.6, 0.4, 0.3]


def test_average_wave_height_in_calm_sea(calm_sea_waves):
    # When
    result = average_wave_height(calm_sea_waves)

    # Then
    assert result < 1.0

El test recibe calm_sea_waves como argumento y pytest se encarga de llamar a la fixture y pasarle el resultado. Así respetas el principio DRY (Don't Repeat Yourself, no te repitas): defines los datos una vez y los reutilizas.

Parametrización: muchos casos con un solo test. Cuando quieres probar la misma función con distintas entradas, no copies el test seis veces. Usa pytest.mark.parametrize:

import pytest

from metocean_tools.waves import average_wave_height


@pytest.mark.parametrize(
    "wave_heights_m, expected",
    [
        pytest.param([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3.5, id="rising_series"),
        pytest.param([2.0, 2.0, 2.0, 2.0, 2.0, 2.0], 2.0, id="flat_series"),
        pytest.param([0.3, 0.4, 0.5], 0.4, id="short_series"),
    ],
)
def test_average_wave_height(wave_heights_m, expected):
    assert average_wave_height(wave_heights_m) == pytest.approx(expected)

Cada pytest.param es un caso, y el id le pone nombre para que, si uno falla, sepas cuál de un vistazo. Un solo test cubre tres escenarios.

Comparar números en coma flotante. Los float no son exactos: 0.1 + 0.2 no es exactamente 0.3 para el ordenador. Comparar con == te dará sustos. Usa pytest.approx:

def test_wave_height_with_decimals():
    result = average_wave_height([1.1, 2.2, 3.3, 4.4, 5.5, 6.6])
    assert result == pytest.approx(3.85)

Probar que una excepción se lanza cuando debe. No basta con probar que el código funciona con datos buenos, hay que probar que falla bien con datos malos. Para eso está pytest.raises:

import pytest

from metocean_tools.waves import SensorReadError, average_wave_height


def test_average_wave_height_raises_with_empty_series():
    # Given
    wave_heights_m = []

    # When / Then
    with pytest.raises(SensorReadError):
        average_wave_height(wave_heights_m)

Este test pasa solo si la función lanza SensorReadError. Si no lanzara nada, o lanzara otra excepción, fallaría. Probar los caminos de error es tan importante como probar los de éxito.

Cobertura con pytest-cov. La cobertura te dice qué líneas de tu código ejecutan los tests y cuáles no:

uv add --dev pytest-cov
uv run pytest --cov=metocean_tools

Es una herramienta útil para descubrir zonas sin probar, pero cuidado con obsesionarse: el 100 % no siempre importa. Cubrir cada línea no garantiza que hayas probado los casos que de verdad importan. Prefiere cubrir bien los caminos críticos (los cálculos, los errores, los límites) a inflar un porcentaje probando trivialidades.

Ejemplo guía: un lector de líneas de la boya

Vamos a juntar todo lo de esta lección en un ejemplo redondo: un lector de las líneas que escribe una boya en su registro, que falla con elegancia ante líneas corruptas.

Una boya guarda sus lecturas en un fichero de texto, una por línea. Cada línea tiene tres campos separados por comas: la hora, la altura de ola en metros y la velocidad del viento en nudos. Por ejemplo: 2026-01-15T10:00,1.8,12.0. Como el fichero viene del mundo real, tarde o temprano alguna línea llegará con un campo de menos, un número imposible o texto donde esperábamos un número.

Escribimos el lector en src/metocean_tools/reader.py. Primero, un dataclass para la lectura ya validada y una excepción con significado:

from dataclasses import dataclass


class SensorReadError(Exception):
    """Se lanza cuando una lectura de la boya no se puede interpretar."""


@dataclass
class BuoyReading:
    """Una lectura validada de la boya."""

    timestamp: str
    wave_height_m: float
    wind_speed_kn: float

Ahora la función que interpreta una línea, validando por el camino:

def parse_reading(line: str) -> BuoyReading:
    """Interpreta una línea del registro de la boya.

    Formato esperado: "timestamp,wave_height_m,wind_speed_kn",
    por ejemplo "2026-01-15T10:00,1.8,12.0".

    Args:
        line: Una línea del fichero de la boya.

    Returns:
        La lectura ya validada.

    Raises:
        SensorReadError: Si la línea no tiene el formato esperado
            o contiene valores imposibles.
    """
    fields = line.strip().split(",")
    if len(fields) != 3:
        raise SensorReadError(f"expected 3 fields, got {len(fields)}: {line!r}")
    timestamp, raw_wave, raw_wind = fields
    try:
        wave_height_m = float(raw_wave)
        wind_speed_kn = float(raw_wind)
    except ValueError as error:
        raise SensorReadError(f"cannot read numbers from {line!r}") from error
    if wave_height_m < 0 or wind_speed_kn < 0:
        raise SensorReadError(f"negative values in {line!r}")
    return BuoyReading(timestamp, wave_height_m, wind_speed_kn)

Fíjate en el raise ... from error: encadena la excepción original con la nuestra, para que en el informe de fallo se vea la causa raíz.

Y ahora la batería de tests parametrizada, con mar en calma, mar gruesa y líneas rotas, en tests/test_reader.py:

import pytest

from metocean_tools.reader import BuoyReading, SensorReadError, parse_reading


@pytest.mark.parametrize(
    "line, expected",
    [
        pytest.param(
            "2026-01-15T10:00,1.8,12.0",
            BuoyReading("2026-01-15T10:00", 1.8, 12.0),
            id="calm_sea",
        ),
        pytest.param(
            "2026-01-15T14:00,4.5,28.0",
            BuoyReading("2026-01-15T14:00", 4.5, 28.0),
            id="rough_sea",
        ),
    ],
)
def test_parse_reading_valid(line, expected):
    assert parse_reading(line) == expected


@pytest.mark.parametrize(
    "line",
    [
        pytest.param("2026-01-15T10:00,1.8", id="missing_field"),
        pytest.param("2026-01-15T10:00,calm,12.0", id="wave_is_not_a_number"),
        pytest.param("2026-01-15T10:00,-1.0,12.0", id="negative_wave"),
        pytest.param("", id="empty_line"),
    ],
)
def test_parse_reading_raises_on_bad_input(line):
    with pytest.raises(SensorReadError):
        parse_reading(line)

Esto es testing profesional en miniatura: casos buenos y casos malos, nombres que describen el escenario, comparación directa del dataclass (que trae su == gratis) y pytest.raises para los errores. El día que alguien toque el lector y rompa algo, estos tests se lo dirán antes de que llegue a producción.

Desafíos de programación atemporales y multiparadigmáticos

Desafíos de programación atemporales y multiparadigmáticos

Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.

Comprar el libro

Ayúdame a seguir escribiendo

Cada café me da un empujón para escribir el siguiente artículo.

Comentarios

Todavía no hay ningún comentario.