6. External dependencies and quality

We close the circle: isolating what touches the outside world (files, network, sensors, system time) and sustaining the code over time. And with that we build the final project.

Testing external dependencies

The problem. Testing code that depends on files, network, system time or sensors is slow, fragile and non-deterministic. If your test calls a real API, it fails when there is no internet, when the API changes or simply when real time makes the response different. A test like that is not trustworthy.

Test doubles. The solution is to replace those dependencies with controlled impostors. There are several types, and in practice the names get mixed up, but the idea is worth it:

  • Stub: a double that returns prepared fixed responses. "When they ask you the temperature, always say 20."
  • Fake: a fake but functional implementation, for example a repository that saves to an in-memory list instead of to a database.
  • Mock: a double that also records how it was called, so you can check "save was called once, with this data".

We are going to reach the protocol in steps, starting with what you already know how to do.

Step 1: the best double is the one you don't need. Before simulating anything, ask yourself whether you can avoid the effect. The hardest thing to test is the one that puts the network inside the logic:

import requests


# Hard to test: it touches the network inside the function
def can_operate_here(latitude: float, longitude: float) -> bool:
    response = requests.get("https://api.example.com/weather")
    weather = response.json()
    return weather["wave_height_m"] < 2.5

To test this you need internet and for real time to cooperate. The simplest solution is the one you already know from the lesson on well-crafted functions: separate the pure calculation from fetching the data. Let one function bring the weather and another, pure one, decide:

def is_operational(wave_height_m: float, wind_speed_kn: float) -> bool:
    """Decides whether operation is possible. Touches nothing external."""
    return wave_height_m < 2.5 and wind_speed_kn < 20.0

This function doesn't need any double: you pass it numbers and check the result.

def test_is_operational_in_calm_weather():
    assert is_operational(wave_height_m=1.2, wind_speed_kn=12.0) is True

The more you push effects toward the edges, the more code is tested this easily.

Step 2: inject the function that brings the data. Sometimes the function has to decide when to request the data, and you can't bring it in advance. In that case, pass it the function that obtains it as an argument. It is exactly what you did in the functional style lesson when passing functions to other functions: the test double is, simply, another function.

from collections.abc import Callable
from dataclasses import dataclass


@dataclass
class WeatherData:
    location: str
    wave_height_m: float
    wind_speed_kn: float


def can_operate(
    fetch_weather: Callable[[float, float], WeatherData],
    latitude: float,
    longitude: float,
) -> bool:
    """Receives the function that brings the weather, not the network directly."""
    weather = fetch_weather(latitude, longitude)
    return weather.wave_height_m < 2.5 and weather.wind_speed_kn < 20.0

In production you pass it the function that calls the API. In the test, a double that is just a function returning fixed data:

def test_can_operate_with_a_fake_fetcher():
    # Given: the double is a plain, ordinary function
    def fake_weather(latitude: float, longitude: float) -> WeatherData:
        return WeatherData("North Sea", 1.2, 12.0)

    # When / Then
    assert can_operate(fake_weather, 56.0, 3.0) is True

This is already dependency injection, without any class involved. You inject into the function what it needs from the outside world.

Step 3: when the edge has several operations, a protocol. Injecting a loose function works great for one piece of data. But if the outside world offers you several related operations (the current weather, the forecast, saving the history), passing three or four loose functions becomes awkward. It is the same leap we made in the functional style lesson, from functions with partial to grouping them in a closure: when there is shared context, it is worth grouping it. Here we group it behind a protocol (an interface, a contract), and this time a class does add value, because it joins a dependency with the operations that use it.

from typing import Protocol


class WeatherProvider(Protocol):
    def get_current_weather(self, latitude: float, longitude: float) -> WeatherData:
        ...

A protocol defines a set of methods that a class must implement. In Python it works by duck typing: if an object has the get_current_weather method with the correct signature, it is considered to comply with the protocol, without needing to inherit from anything.

The real implementation talks to the API; the test one returns fixed data:

class FixedWeatherProvider:
    """Test implementation: returns fixed data."""

    def __init__(self, weather: WeatherData):
        self._weather = weather

    def get_current_weather(self, latitude: float, longitude: float) -> WeatherData:
        return self._weather

And now our logic, which receives the provider by dependency injection:

class OperationalAdvisor:
    """Advises whether operation is possible based on the weather at a position."""

    def __init__(self, weather_provider: WeatherProvider):
        self._weather_provider = weather_provider

    def can_operate(self, latitude: float, longitude: float) -> bool:
        weather = self._weather_provider.get_current_weather(latitude, longitude)
        return weather.wave_height_m < 2.5 and weather.wind_speed_kn < 20.0

The test is clean, fast and deterministic, without touching the network:

from metocean_tools.weather import (
    FixedWeatherProvider,
    OperationalAdvisor,
    WeatherData,
)


def test_can_operate_in_calm_weather():
    # Given
    calm = WeatherData(location="North Sea", wave_height_m=1.2, wind_speed_kn=12.0)
    advisor = OperationalAdvisor(FixedWeatherProvider(calm))

    # When
    result = advisor.can_operate(56.0, 3.0)

    # Then
    assert result is True


def test_cannot_operate_in_rough_weather():
    # Given
    rough = WeatherData(location="North Sea", wave_height_m=4.5, wind_speed_kn=30.0)
    advisor = OperationalAdvisor(FixedWeatherProvider(rough))

    # When / Then
    assert advisor.can_operate(56.0, 3.0) is False

unittest.mock and pytest-mock. If you don't want to write the double by hand, the standard unittest.mock library generates mocks for you, and create_autospec makes them respecting the original's signature. pytest-mock adds the mocker fixture, more convenient inside pytest:

uv add --dev pytest-mock
from unittest.mock import create_autospec

from metocean_tools.weather import OperationalAdvisor, WeatherData, WeatherProvider


def test_can_operate_with_mock():
    # Given
    provider = create_autospec(WeatherProvider)
    provider.get_current_weather.return_value = WeatherData(
        location="Med", wave_height_m=1.0, wind_speed_kn=10.0
    )
    advisor = OperationalAdvisor(provider)

    # When
    result = advisor.can_operate(40.0, 3.0)

    # Then
    assert result is True
    provider.get_current_weather.assert_called_once_with(40.0, 3.0)

Why functional style makes this easier. Here a circle of the course closes: if you isolate the effects, you have less to simulate. A pure function that receives the data and returns a result doesn't need mocks, you test it with values and that's it. The more you push the effects (network, files, time) toward the edges of the program, the smaller the core that needs doubles.

Unit tests versus integration tests. A unit test tests an isolated piece (a pure function, a class with its dependencies simulated): it is fast and precise. An integration test tests that several pieces actually fit together (that the parser reads a real file, that the provider talks to the API): it is slower and more fragile, but it verifies that the whole works. You need both, with many fast unit tests and a few integration ones at the key points.

From script to tool

We already have tested logic. Now we turn it into a real tool.

Modules and packages. Split the code into pieces that make sense. Each .py file is a module; a folder with modules (and an __init__.py) is a package. In metocean-tools we already have waves.py, reader.py, stream.py, weather.py. Each groups its own thing, so you find things without going through a thousand-line file.

Separate the calculation logic from input/output. Golden rule for testable code: the calculation on one side; reading and writing on the other. Pure functions calculate; a thin layer at the edges reads files and writes results. This way the bulk of your code is tested without touching the disk.

Configuration outside the code: environment variables. The paths, the credentials and the settings that change between machines don't go written in the code. They go in environment variables:

import os

data_dir = os.environ.get("METOCEAN_DATA_DIR", "./data")

This way the same code works on your laptop and on the server without touching a line.

Event logging with logging instead of print. For a serious tool, print falls short. logging gives you levels (info, warning, error), timestamps and the possibility of sending the messages to a file or silencing them, without deleting anything:

import logging

logger = logging.getLogger(__name__)


def process_file(path: str) -> None:
    logger.info("processing %s", path)
    ...
    logger.warning("skipped %d corrupted lines", skipped)

A command-line interface with typer. So the tool runs from the terminal with arguments, typer builds a CLI from functions with type hints, with almost no effort:

uv add typer
import typer

from metocean_tools.stream import average_wave_height_feet

app = typer.Typer(help="Marine data processing tools.")


@app.command()
def wave_average(path: str) -> None:
    """Displays the average wave height of a buoy file."""
    result = average_wave_height_feet(path)
    typer.echo(f"Average wave height: {result:.2f} ft")


if __name__ == "__main__":
    app()

And from the terminal:

uv run python -m metocean_tools.cli wave-average buoy.csv

typer gives you the help (--help), the argument validation and the error messages practically for free.

Linter and closing

The last thing: the tool that sustains code quality without you having to keep an eye on it.

Ruff as linter and formatter. Ruff does two jobs: it formats (the spaces, the commas) and acts as a linter, that is, it detects problems before running (unused variables, superfluous imports, functions with too many arguments as we saw in the lesson on well-crafted functions):

uv run ruff check .
uv run ruff format .

It is worth running it often, and above all before you consider a batch of changes done: it is the cheapest way to catch problems, even before running the tests.

Test-driven refactoring. Now you have the complete safety net. With tests and linter you can improve the code without fear: you change the implementation internally, run the tests, and if they stay green, you know you haven't broken anything. Refactoring stops being a gamble and becomes routine.

Closing. With this you have the complete professional development cycle in Python: reproducible environment, pure and well-designed functions, tests from the start, functional style to transform data, isolated external dependencies and tools that watch over quality for you. What remains is to practice it until it becomes your natural way of working.

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.