4. Testing from the start and error handling

We introduce testing early, almost at the beginning and not at the end, on purpose: we want it to accompany the rest of the course. When you test what you write, you write better.

First test

Why you test code. Two reasons worth their weight in gold:

  1. Refactor without fear. With tests, changing the code internally stops being dizzying: if you break something, a test turns red and warns you instantly.
  2. Document behavior. A test is an executable example of how your code is used and what you expect from it. It doesn't go stale like a comment, because if you lie, it fails.

Even though testing may seem like wasting time, it actually saves on every line of code. At the start of the project productivity is low, but as it grows it saves you the hours you were going to spend hunting bugs. Before telling yourself "I don't do testing because it's too small", remember that every project starts out small. Later it will be too big to test with enthusiasm.

Our first test with pytest. We install the tool:

uv add --dev pytest

The --dev flag indicates that pytest is a development dependency: you need it to work on the project, but it is not part of the product you deliver.

And we write a test for our average_wave_height function, in 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

We run it:

uv run pytest

And pytest walks the folder, finds the test, runs it and tells you whether it passes. If it passes, you breathe easy. If it fails, it shows you exactly what you expected and what you got.

The structure of a test: Given-When-Then. You will have seen the # Given, # When, # Then comments. They are not decoration, they are a pattern that organizes the test into three blocks:

  1. Given: you prepare the scenario, the input data, the conditions.
  2. When: you run the code you want to test.
  3. Then: you check that the result is the expected one.

It is also known as Arrange, Act, Assert. They are the same idea. Always split your tests this way and they will read on their own.

Test names that describe the case. A test's name is the description of what it verifies. test_average_wave_height_with_empty_list tells you instantly what failed when it turns red. test_1 tells you nothing.

What an assertion is. assert is the check: it is followed by an expression that must be true. If it is, the test continues. If not, pytest stops and shows you the mismatch in full detail: what value you expected and which one you got. Learning to read that failure report is half the battle.

Where tests live and how they are named. Test files start with test_ (or end in _test.py), and live in the tests/ folder. Test functions also start with test_. pytest finds them by that convention, without you having to register them anywhere.

Error handling

A professional program is not the one that never fails, it is the one that fails well: it says what happened, where and why, instead of blowing up with a cryptic message or, worse, carrying on with corrupted data.

Exceptions: what they are and when to catch them. An exception is Python's way of saying "something happened here that I can't handle on my own". The mental rule: catch an exception only if you can do something useful with it. If not, let it rise, let it reach someone who does know what to do.

Don't silence errors. The worst anti-pattern in error handling is this:

# NEVER do this
try:
    process_sensor_data(raw)
except Exception:
    pass

That empty except swallows any problem and carries on as if nothing happened. It is like digging a hole and covering it with a rug: the day something really fails, you will have no idea where. If you catch, do something (log, warn, return a fallback value), but don't stay quiet.

Create your own meaningful exceptions. When something fails in your domain, say what failed with a named exception:

class SensorReadError(Exception):
    """Raised when a sensor reading cannot be interpreted."""

Compare raise SensorReadError("negative wave height: -3.2 m") with a generic ValueError. The first tells you exactly what happened and you can catch it selectively higher up, without also catching errors that have nothing to do with it.

Now we can tie up the loose end from the previous lesson, the empty list:

class SensorReadError(Exception):
    """Raised when a sensor reading cannot be interpreted."""


def average_wave_height(wave_heights_m: list[float]) -> float:
    """Calculates the average wave height of a series of readings.

    Args:
        wave_heights_m: Wave heights, in meters.

    Returns:
        The average height, in meters.

    Raises:
        SensorReadError: If the series is empty.
    """
    if not wave_heights_m:
        raise SensorReadError("no wave readings to average")
    return sum(wave_heights_m) / len(wave_heights_m)

Input data validation. Golden rule: never trust an external file. A sensor file may come with extra columns, impossible values or wrong types. Validating by hand with if all over the place is tedious and cases slip through. That is what libraries like msgspec (modern, fast, written in C) or pydantic are for:

import msgspec


class SensorReading(msgspec.Struct, strict=True):
    """Validated reading from a buoy sensor."""

    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)

With strict=True, if a type doesn't fit exactly, an error is raised. You validate at the program's border, where the data comes in, and from there you work calmly knowing it is correct.

Recording and representing data with dataclasses and Enum. To model data we use records, not object hierarchies. A dataclass to group fields and an Enum for a closed set of values:

from dataclasses import dataclass
from enum import Enum


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


@dataclass
class OperationalWindow:
    """Work window evaluated from the sea state."""

    wave_height_m: float
    condition: SeaCondition
    is_operational: bool

The Enum prevents someone from writing "clam" instead of "calm" and not noticing until it is too late.

Debugging without print

When something fails, the instinctive reaction is to scatter print through the code to see where it goes. It works, yes, but it dirties the code and then you have to remember to delete it all. There is a better way, standard and without installing anything.

breakpoint(). Available since Python 3.7. Write breakpoint() on the line where you want to stop and run the program normally. When it gets there, it stops and opens an interactive (Pdb) console with the state frozen at that point:

def average_wave_height(wave_heights_m: list[float]) -> float:
    total = sum(wave_heights_m)
    breakpoint()  # Execution stops here
    return total / len(wave_heights_m)

From the (Pdb) you can inspect variables, modify them on the fly and run any Python expression. It is like opening the hood with the engine running.

Basic commands:

Command Action
n (next) runs the current line without entering functions
s (step) same, but enters the called function
c (continue) continues until the next breakpoint or the end
l (list) shows the code around the current line
p expr prints a variable or expression: p total
pp expr same but pretty, useful for dictionaries and lists
w (where) shows the call stack
a (args) shows the arguments of the current function
q (quit) aborts execution

Ignore all breakpoints without deleting them. When you finish debugging but haven't yet removed the breakpoint(), run with this environment variable and it won't stop at any of them:

PYTHONBREAKPOINT=0 uv run demo.py

Post-mortem mode. If a script blows up and doesn't have breakpoint(), you can ask Python to drop you right at the point of failure to inspect the state:

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

All of this connects with the graphical debugger in VSCode that we configured in the environment setup lesson: it is the same idea with buttons instead of commands. Use whichever is most comfortable for you, but leave print for printing results, not for debugging.

Fixtures, parametrization and coverage

You already know how to write a test. Now we are going to write many without repeating yourself.

Fixtures: reusing test data and objects. If several tests need the same object or the same data, don't create them over and over. A fixture is a function decorated with @pytest.fixture that prepares something and passes it to any test that requests it by its name:

import pytest

from metocean_tools.waves import average_wave_height


@pytest.fixture
def calm_sea_waves():
    """Series of calm sea waves, in meters."""
    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

The test receives calm_sea_waves as an argument and pytest takes care of calling the fixture and passing it the result. This way you respect the DRY principle (Don't Repeat Yourself): you define the data once and reuse it.

Parametrization: many cases with a single test. When you want to test the same function with different inputs, don't copy the test six times. Use 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)

Each pytest.param is a case, and the id gives it a name so that, if one fails, you know which at a glance. A single test covers three scenarios.

Comparing floating-point numbers. float values are not exact: 0.1 + 0.2 is not exactly 0.3 for the computer. Comparing with == will give you scares. Use 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)

Testing that an exception is raised when it should be. It is not enough to test that the code works with good data, you have to test that it fails well with bad data. That is what pytest.raises is for:

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)

This test passes only if the function raises SensorReadError. If it raised nothing, or raised a different exception, it would fail. Testing the error paths is as important as testing the success ones.

Coverage with pytest-cov. Coverage tells you which lines of your code the tests run and which they don't:

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

It is a useful tool to discover untested areas, but be careful not to obsess over it: 100% doesn't always matter. Covering every line doesn't guarantee that you have tested the cases that really matter. Prefer covering the critical paths well (the calculations, the errors, the limits) to inflating a percentage by testing trivialities.

Guided example: a buoy line reader

We are going to bring together everything from this lesson into a well-rounded example: a reader of the lines a buoy writes to its log, which fails gracefully in the face of corrupted lines.

A buoy stores its readings in a text file, one per line. Each line has three fields separated by commas: the time, the wave height in meters and the wind speed in knots. For example: 2026-01-15T10:00,1.8,12.0. Since the file comes from the real world, sooner or later some line will arrive with a missing field, an impossible number or text where we expected a number.

We write the reader in src/metocean_tools/reader.py. First, a dataclass for the already-validated reading and a meaningful exception:

from dataclasses import dataclass


class SensorReadError(Exception):
    """Raised when a buoy reading cannot be interpreted."""


@dataclass
class BuoyReading:
    """A validated buoy reading."""

    timestamp: str
    wave_height_m: float
    wind_speed_kn: float

Now the function that interprets a line, validating along the way:

def parse_reading(line: str) -> BuoyReading:
    """Interprets a line from the buoy log.

    Expected format: "timestamp,wave_height_m,wind_speed_kn",
    for example "2026-01-15T10:00,1.8,12.0".

    Args:
        line: A line from the buoy file.

    Returns:
        The already-validated reading.

    Raises:
        SensorReadError: If the line does not have the expected format
            or contains impossible values.
    """
    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)

Notice the raise ... from error: it chains the original exception with ours, so that in the failure report you can see the root cause.

And now the parametrized battery of tests, with calm sea, rough sea and broken lines, in 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)

This is professional testing in miniature: good cases and bad cases, names that describe the scenario, direct comparison of the dataclass (which brings its == for free) and pytest.raises for the errors. The day someone touches the reader and breaks something, these tests will tell them before it reaches production.

This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.

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.

Buy the book

Will you buy me a coffee?

This is how I keep writing without ads or paywalls.

Comments

There are no comments yet.