8. Dependencias externas y calidad

Cerramos el círculo: aislar lo que toca el mundo exterior (ficheros, red, sensores, hora del sistema) y sostener el código en el tiempo. Y con eso montamos el proyecto final.

Probar dependencias externas

El problema. Probar código que depende de ficheros, red, hora del sistema o sensores es lento, frágil y no determinista. Si tu test llama a una API de verdad, falla cuando no hay internet, cuando la API cambia o simplemente cuando el tiempo real hace que la respuesta sea distinta. Un test así no es de fiar.

Dobles de prueba. La solución es sustituir esas dependencias por impostores controlados. Hay varios tipos, y en la práctica los nombres se mezclan, pero la idea vale la pena:

  • Stub: un doble que devuelve respuestas fijas preparadas. "Cuando te pregunten la temperatura, di siempre 20."
  • Fake: una implementación de mentira pero funcional, por ejemplo un repositorio que guarda en una lista en memoria en lugar de en una base de datos.
  • Mock: un doble que además registra cómo lo han llamado, para que puedas comprobar "se llamó a save una vez, con estos datos".

Vamos a llegar al protocolo por escalones, empezando por lo que ya sabes hacer.

Escalón 1: el mejor doble es el que no necesitas. Antes de simular nada, pregúntate si puedes evitar el efecto. La forma más difícil de probar es la que mete la red dentro de la lógica:

import requests


# Difícil de probar: toca la red dentro de la función
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

Para probar esto necesitas internet y que el tiempo real coopere. La solución más sencilla es la que ya conoces de la lección sobre funciones bien hechas: separa el cálculo puro de la obtención de datos. Que una función traiga el tiempo y otra, pura, decida:

def is_operational(wave_height_m: float, wind_speed_kn: float) -> bool:
    """Decide si se puede operar. No toca nada de fuera."""
    return wave_height_m < 2.5 and wind_speed_kn < 20.0

Esta función no necesita ningún doble: le pasas números y compruebas el resultado.

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

Cuanto más empujes los efectos hacia los bordes, más código se prueba así de fácil.

Escalón 2: inyecta la función que trae los datos. A veces la función tiene que decidir cuándo pedir el dato, y no puedes traerlo de antemano. En ese caso, pásale como argumento la función que lo obtiene. Es exactamente lo que hiciste en la lección de estilo funcional al pasar funciones a otras funciones: el doble de prueba es, simplemente, otra función.

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:
    """Recibe la función que trae el tiempo, no la red directamente."""
    weather = fetch_weather(latitude, longitude)
    return weather.wave_height_m < 2.5 and weather.wind_speed_kn < 20.0

En producción le pasas la función que llama a la API. En el test, un doble que es solo una función que devuelve datos fijos:

def test_can_operate_with_a_fake_fetcher():
    # Given: el doble es una función normal y corriente
    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

Esto ya es inyección de dependencias, sin ninguna clase de por medio. Le inyectas a la función lo que necesita del mundo exterior.

Escalón 3: cuando el borde tiene varias operaciones, un protocolo. Inyectar una función suelta va de maravilla para un dato. Pero si el mundo exterior te ofrece varias operaciones relacionadas (el tiempo actual, la previsión, guardar el histórico), pasar tres o cuatro funciones sueltas se vuelve incómodo. Es el mismo salto que dimos en la lección de estilo funcional, de funciones con partial a agruparlas en un closure: cuando hay contexto compartido, conviene agruparlo. Aquí lo agrupamos tras un protocolo (una interfaz, un contrato), y esta vez una clase sí aporta, porque une una dependencia con las operaciones que la usan.

from typing import Protocol


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

Un protocolo define un conjunto de métodos que una clase debe implementar. En Python funciona por duck typing: si un objeto tiene el método get_current_weather con la firma correcta, se considera que cumple el protocolo, sin necesidad de heredar de nada.

La implementación real habla con la API; la de test devuelve datos fijos:

class FixedWeatherProvider:
    """Implementación de test: devuelve datos fijos."""

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

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

Y ahora nuestra lógica, que recibe el proveedor por inyección de dependencias:

class OperationalAdvisor:
    """Aconseja si se puede operar según el tiempo en una posición."""

    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

El test es limpio, rápido y determinista, sin tocar la red:

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 y pytest-mock. Si no quieres escribir el doble a mano, la librería estándar unittest.mock genera mocks por ti, y create_autospec los hace respetando la firma del original. pytest-mock añade la fixture mocker, más cómoda dentro de 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)

Por qué el estilo funcional facilita esto. Aquí se cierra un círculo del curso: si aíslas los efectos, tienes menos que simular. Una función pura que recibe los datos y devuelve un resultado no necesita mocks, se prueba con valores y ya. Cuanto más empujes los efectos (red, ficheros, hora) hacia los bordes del programa, más pequeño es el núcleo que necesita dobles.

Tests unitarios frente a tests de integración. Un test unitario prueba una pieza aislada (una función pura, una clase con sus dependencias simuladas): es rápido y preciso. Un test de integración prueba que varias piezas encajan de verdad (que el parser lee un fichero real, que el proveedor habla con la API): es más lento y frágil, pero verifica que el conjunto funciona. Necesitas ambos, con muchos unitarios rápidos y unos pocos de integración en los puntos clave.

De script a herramienta

Ya tenemos lógica probada. Ahora la convertimos en una herramienta de verdad.

Módulos y paquetes. Divide el código en piezas con sentido. Cada fichero .py es un módulo; una carpeta con módulos (y un __init__.py) es un paquete. En metocean-tools ya tenemos waves.py, reader.py, weather.py, archive.py y batch.py. Cada uno agrupa lo suyo, y así encuentras las cosas sin recorrer un archivo de mil líneas.

Separar la lógica de cálculo de la entrada/salida. Regla de oro para código comprobable: el cálculo, por un lado; leer y escribir, por otro. Las funciones puras calculan; una capa fina en los bordes lee ficheros y escribe resultados. Así el grueso de tu código se prueba sin tocar el disco.

Configuración fuera del código: variables de entorno. Las rutas, las credenciales y los ajustes que cambian entre máquinas no van escritos en el código. Van en variables de entorno:

import os
from pathlib import Path

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

Así el mismo código funciona en tu portátil y en el servidor sin tocar una línea. Fíjate en que envolvemos el valor en un Path desde el primer momento, como aprendimos en la lección de rutas: a partir de ahí el resto del programa trabaja con rutas de verdad, no con texto.

Registro de eventos con logging en lugar de print. Para una herramienta seria, print se queda corto. logging te da niveles (info, warning, error), marcas de tiempo y la posibilidad de mandar los mensajes a un fichero o silenciarlos, sin borrar nada:

import logging

logger = logging.getLogger(__name__)


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

Una interfaz de línea de comandos con cyclopts. Para que la herramienta se ejecute desde la terminal con argumentos, cyclopts construye una CLI a partir de funciones con type hints, casi sin esfuerzo:

uv add cyclopts
from pathlib import Path

from cyclopts import App

from metocean_tools.batch import average_of_file

app = App(help="Herramientas de procesamiento de datos marinos.")


@app.command
def wave_average(path: Path) -> None:
    """Muestra la altura media de ola de un fichero de la boya."""
    result = average_of_file(path)
    print(f"Average wave height: {result:.2f} m")


if __name__ == "__main__":
    app()

Y desde la terminal:

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

cyclopts te da la ayuda (--help), la validación de argumentos y los mensajes de error prácticamente gratis.

Linter, tipos y cierre

Lo último: las herramientas que sostienen la calidad del código sin que tengas que estar pendiente.

Ruff como linter y formateador. Ruff hace dos trabajos: formatea (los espacios, las comas) y hace de linter, es decir, detecta problemas antes de ejecutar (variables sin usar, imports que sobran, funciones con demasiados argumentos como vimos en la lección sobre funciones bien hechas):

uv run ruff check .
uv run ruff format .

Conviene ejecutarlo a menudo, y sobre todo antes de dar por buena una tanda de cambios: es la forma más barata de cazar problemas, antes incluso de correr los tests.

Comprobar los tipos con un type checker. En todo el curso has puesto type hints, pero hasta ahora eran solo documentación: nadie los verificaba. Un type checker los convierte en una red de seguridad de verdad. Analiza el código sin ejecutarlo y te avisa si pasas un str donde esperabas un float, si una función puede devolver None sin que lo trates o si te has equivocado con el nombre de un campo.

Ruff no hace este trabajo. El estándar es mypy, y en tu editor la extensión Pylance ya usa pyright por debajo mientras escribes. Para correrlo en la terminal o en integración continua:

uv add --dev mypy
uv run mypy src

Si quieres el mismo espíritu de Ruff (velocidad y mismo equipo, el de Astral), echa un ojo a ty, su type checker nuevo escrito en Rust, aún madurando pero muy rápido; se prueba sin instalar nada con uvx ty check. La regla mental: los hints sin type checker son buenas intenciones; con un checker detrás, son una red que caza errores antes de que el programa llegue a ejecutarse.

Refactorización guiada por los tests. Ahora ya tienes la red de seguridad completa. Con tests y linter puedes mejorar el código sin miedo: cambias la implementación por dentro, ejecutas los tests, y si siguen verdes, sabes que no has roto nada. Refactorizar deja de ser una apuesta y se convierte en rutina.

Cierre. Con esto tienes el ciclo completo de desarrollo profesional en Python: entorno reproducible, funciones puras y bien diseñadas, tests desde el principio, estilo funcional para transformar datos, clases cuando de verdad aportan, rutas y fechas bien tratadas, concurrencia cuando hace falta, dependencias externas aisladas y herramientas (linter y type checker) que vigilan la calidad por ti. Lo que queda es practicarlo hasta que sea tu forma natural de trabajar.

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.