Testing en Python con pytest: de las bases a las técnicas avanzadas

No te voy a convencer de que hacer testing es necesario, si estás leyendo este artículo es porque ya lo sabes. En su lugar, voy a ir directo al grano para enseñarte a trabajar con Pytest con el objetivo de realizar pruebas unitarias, de integración, funcionales, etc. Me centraré en diversas técnicas y herramientas que puedes usar en tus proyectos pythónicos, aunque podrás usar los mismos patrones en otros lenguajes.

Bibliotecas de testing en Python

Empecemos con una lista de bibliotecas esenciales para hacer testing en Python:

  • pytest: Framework de testing principal en Python. Soporta pruebas unitarias, de integración, funcionales, etc. Muy extensible con plugins.
  • pytest-env: Gestión de variables de entorno en las pruebas. Útil para configurar cadenas de conexión a bases de datos, claves de API, etc.
  • pytest-xdist: Ejecución de pruebas en paralelo en varios núcleos de CPU. Útil para acelerar la suite de pruebas.
  • pytest-asyncio: Ejecución de pruebas para código que usa asyncio.
  • respx: Simula peticiones HTTP hechas con la biblioteca httpx (compatible con código asíncrono).
  • time-machine: Alternativa a freezegun. Más rápida y funciona mejor con código asíncrono.
  • faker: Genera datos falsos (nombres, correos, direcciones, números de teléfono, etc.) para las pruebas.
  • polyfactory: Biblioteca de factorías moderna con un gran soporte para Pydantic, dataclasses y attrs.
  • syrupy: Pruebas de snapshot para pytest.
  • dirty-equals: Aserciones de igualdad flexibles como IsUUID(), IsDatetime(), IsPartialDict(). Ideal para verificar partes de las respuestas.
  • hypothesis: La biblioteca de referencia para pruebas basadas en propiedades. Genera casos de prueba automáticamente a partir de especificaciones.

Usaremos gran parte de ellas y otras se complementarán entre sí, pero todo dependerá de pytest como framework de testing principal.

Anatomía de una prueba

Empecemos con lo más básico: cómo escribir una prueba.

Debes crear un archivo de prueba separado, normalmente con el prefijo test_ o el sufijo _test.py. Dentro de ese archivo, defines funciones de prueba que también deben seguir la convención de nombrado (comenzar con test_).

Cada función de prueba debe contener una o varias aserciones que verifiquen el comportamiento esperado del código que estás probando.

Por ejemplo, dentro de un archivo llamado test_sum.py, podríamos tener la siguiente prueba para una función sum:

def test_sum():
    assert sum([1, 2, 3]) == 6

Usa un nombre de función descriptivo para cada prueba, y divide la prueba en 3 partes: given (preparación de datos), when (ejecución del código a probar) y then (verificación de resultados).

def is_tropic(latitude, longitude):
    pass

def test_is_tropic():
    # Given
    latitude = 0
    longitude = 0

    # When
    result = is_tropic(latitude, longitude)

    # Then
    assert result #= True


def test_is_not_tropic():
    # Given
    latitude = 45
    longitude = 45

    # When
    result = is_tropic(latitude, longitude)

    # Then
    assert not result #= False

Factory fixtures o crear objetos de prueba reutilizables

Supongamos que para probar una función necesitamos crear un objeto complejo, como una biblioteca que me da el clima actual dependiendo de una ubicación. En lugar de crear ese objeto cada vez que lo necesitemos en nuestras pruebas, podemos usar una fixture de pytest para crear un objeto de prueba reutilizable.

Así lo haríamos si no tuviera una fixture:

def test_get_current_weather_new_york():
    # Given
    weather_service = WeatherService()

    # When
    weather = weather_service.get_current_weather(location="New York")

    # Then
    assert weather.temperature > 0


def test_get_current_weather_london():
    # Given
    weather_service = WeatherService()

    # When
    weather = weather_service.get_current_weather(location="London")

    # Then
    assert weather.temperature > 0

Cada vez que se lanza un test, el código de instanciación se repite, lo cual viola el principio DRY y hace el mantenimiento más costoso.

El principio DRY (Don't Repeat Yourself) nos dice que no debemos repetir código, sino abstraerlo en funciones, clases o fixtures para que sea más fácil de mantener y reutilizar.

Y así lo haríamos usando una fixture:

import pytest


@pytest.fixture
def weather_service():
    return WeatherService()


def test_get_current_weather_new_york(weather_service):
    # Given
    # No necesitamos crear el objeto WeatherService, ya lo tenemos como argumento

    # When
    weather = weather_service.get_current_weather(location="New York")

    # Then
    assert weather.temperature > 0


def test_get_current_weather_london(weather_service):
    # Given
    # No necesitamos crear el objeto WeatherService, ya lo tenemos como argumento

    # When
    weather = weather_service.get_current_weather(location="London")

    # Then
    assert weather.temperature > 0

También lo puedes usar para crear objetos de prueba con datos específicos, leer archivos de prueba (CSV, JSON, etc.), constantes de configuración, etc.

Si trabajas con modelos Pydantic o dataclasses, polyfactory te permite generar instancias de prueba sin tener que rellenar cada campo a mano:

from polyfactory.factories.pydantic_factory import ModelFactory
from pydantic import BaseModel


class WeatherData(BaseModel):
    location: str
    temperature_celsius: float
    conditions: str
    humidity: int


class WeatherDataFactory(ModelFactory):
    __model__ = WeatherData


def test_weather_factory():
    weather = WeatherDataFactory.build()
    assert isinstance(weather.temperature_celsius, float)

    weather_list = WeatherDataFactory.batch(size=5)
    assert len(weather_list) == 5

build() genera una instancia con valores aleatorios válidos. Puedes sobrescribir los campos que te importen y dejar que la librería rellene el resto.

Test-driven Development Triangulation

¿Cómo puedo romper el miedo al folio en blanco a la hora de escribir pruebas? La triangulación es una técnica que te sortea el bloqueo inicial. Eso sí, ¡no confundas con el concepto de TDD tradicional!

TDD (Test Driven Development) es la práctica de programación sobre testing más utilizada por desarrolladores. Consiste en seguir un diseño de trabajo opuesto al testing tradicional escribiendo primero la prueba y solo añadir nuevo código si falla.

Con sistema de triangulación, en lugar de escribir primero la prueba que falla, escribes tres pruebas que pasan. Luego refactorizas el código para eliminar la duplicación entre ellas.

Entendámoslo con un ejemplo práctico. Supongamos que queremos implementar una función que verifica si un string es un anagrama de otro (2 palabras que tienen las mismas letras pero en diferente orden).

Primero creamos una prueba que pase muy simple:

def test_anagram_simple():
    assert is_anagram("amor", "roma") #=> True

# Primera implementación: muy simple, con un caso específico
def is_anagram(s1, s2):
    return sorted(s1) == sorted(s2)

Después, escribimos otra prueba que pase pero con un caso diferente:

def test_anagram_with_spaces():
    assert is_anagram("amor", "a rom")
    assert is_anagram("a rom", "amor")
    assert is_anagram("maor", "a m o r")

# Segunda implementación: expandimos con la primera excepción, pero aún no es general
def is_anagram(s1, s2):
    return sorted(s1.replace(" ", "")) == sorted(s2.replace(" ", ""))

Y por último, una tercera prueba que pase pero con mayúsculas:

def test_anagram_with_uppercase():
    assert is_anagram("Amor", "Roma")
    assert is_anagram("a Rom", "Amor")
    assert is_anagram("maOr", "a m o r")
    assert is_anagram("Amor", "ROMA")

# Tercera implementación: ahora sí, tenemos una función general
def is_anagram(s1, s2):
    return sorted(s1.replace(" ", "").lower()) == sorted(s2.replace(" ", "").lower())

Y desde ahí, ya podemos refactorizar el código para eliminar la duplicación entre las pruebas, o escribir más pruebas para cubrir casos adicionales (como acentos, caracteres especiales, etc.).

Sin embargo, cuando empezamos a tener un gran listado de pruebas para una misma función, nos será de ayuda utilizar parametrize para evitar la duplicación entre ellas.

Parametrización de pruebas para ejecutar el mismo test con diferentes conjuntos de datos

Vamos a rescatar el ejemplo anterior, la última prueba que escribimos, para parametrizarla y añadir muchos más casos.

Con pytest.mark.parametrize podemos ejecutar el mismo test con diferentes conjuntos de datos.

Primero definimos el decorador parametrize con los diferentes casos de prueba que queremos ejecutar.

import pytest

@pytest.mark.parametrize(
    "string_1, string_2",
    [
        pytest.param("amor", "roma", id="simple"),
        pytest.param("amor", "a rom", id="with_spaces"),
        pytest.param("Amor", "Roma", id="with_uppercase"),
        pytest.param("a Rom", "Amor", id="with_spaces_and_uppercase"),
        pytest.param("maOr", "a m o r", id="with_a_lot_of_spaces_and_uppercase"),
        pytest.param("Amor", "ROMA", id="with_uppercase_2"),
        pytest.param("amór", "romá", id="with_accents"),
        pytest.param("#amor", "rom#a", id="with_special_characters"),
    ]
)

Cada tupla contiene los argumentos que se pasarán a la función de test. El parámetro id es opcional, pero es muy útil para identificar cada caso de prueba.

Un decorador por sí mismo no es nada práctico. Ahora definimos la función de test que se ejecutará con cada par de strings:

def test_check_anagram(string_1, string_2):
    assert is_anagram(string_1, string_2)

pytest se encargará de ejecutar esta función de test con cada par de strings definido en la lista de parámetros, sin hacer nada más.

Todo unido sería algo así:

import pytest

@pytest.mark.parametrize(
    "string_1, string_2",
    [
        pytest.param("amor", "roma", id="simple"),
        pytest.param("amor", "a rom", id="with_spaces"),
        pytest.param("Amor", "Roma", id="with_uppercase"),
        pytest.param("a Rom", "Amor", id="with_spaces_and_uppercase"),
        pytest.param("maOr", "a m o r", id="with_a_lot_of_spaces_and_uppercase"),
        pytest.param("Amor", "ROMA", id="with_uppercase_2"),
        pytest.param("amór", "romá", id="with_accents"),
        pytest.param("#amor", "rom#a", id="with_special_characters"),
    ]
)
def test_check_anagram(string_1, string_2):
    assert is_anagram(string_1, string_2)

def is_anagram(s1, s2):
    return sorted(s1.replace(" ", "").lower()) == sorted(s2.replace(" ", "").lower())

Se testean abstracciones/protocolos/contratos, no implementaciones concretas

Un problema muy común a la hora de escribir pruebas es que tendemos a testear implementaciones concretas en lugar de abstracciones o protocolos.

Por ejemplo, supongamos que queremos hacer uso de un recurso externo como una API que nos devuelve información climática (api.open-meteo.com).

Un patrón común, y erróneo, sería incluir llamadas a esa API directamente en nuestras pruebas:

import requests

def test_get_current_weather():
    # Given
    response = requests.get("https://api.open-meteo.com/v1/forecast?latitude=35&longitude=139&hourly=temperature_2m")
    data = response.json()

    # When
    temperature = data["hourly"]["temperature_2m"][0]

    # Then
    assert temperature > 0

¿Qué pasa si la API no está disponible? ¿O si el formato de la respuesta cambia? ¿O si el clima cambia y la temperatura es menor o igual a 0? Estas pruebas serían frágiles y no confiables. Además, que cada mínimo cambio en la API nos obligaría a cambiar todas las pruebas que dependen de ella.

En su lugar usa una abstracción o protocolo para interactuar con esa API, y luego haz mocking o stubbing de esa abstracción en tus pruebas.

import requests
from typing import Protocol

# Definimos la interfaz (Protocol) que deben cumplir las implementaciones
class WeatherGatewayInterface(Protocol):
    def get_temperature(self, latitude: float, longitude: float) -> float | None:
        pass

# Implementación real usando Open-Meteo
class OpenMeteoGateway:
    def get_temperature(self, latitude: float, longitude: float) -> float | None:
        url = "https://api.open-meteo.com/v1/forecast"
        params = {
            "latitude": latitude,
            "longitude": longitude,
            "current_weather": "true"
        }
        try:
            response = requests.get(url, params=params, timeout=5)
            response.raise_for_status()
            data = response.json()
            return data["current_weather"]["temperature"]
        except Exception:
            # Según la arquitectura limpia, gestionamos las excepciones devolviendo None
            # para que el caso de uso se encargue de estructurar el error
            return None

@pytest.fixture
def weather_gateway():
    return OpenMeteoGateway()

def test_get_current_weather(weather_gateway):
    # Given
    latitude = 35
    longitude = 139

    # When
    temperature = weather_gateway.get_temperature(latitude, longitude)

    # Then
    assert temperature > 0

Un protocolo es una interfaz que define un conjunto de métodos y propiedades que una clase debe implementar.

Entonces seguiremos un diseño desacoplado donde tendremos una interfaz WeatherGatewayInterface que define el contrato que deben cumplir las implementaciones, y una implementación concreta OpenMeteoGateway que interactúa con la API de Open-Meteo. En las pruebas podemos redondear creando una fixture que nos devuelva una instancia de OpenMeteoGateway.

¿Cómo sabe OpenMeteoGateway que debe cumplir con WeatherGatewayInterface? No lo sabe, pero no es necesario. En Python, el duck typing se encarga de eso. Mientras OpenMeteoGateway tenga un método get_temperature con la misma firma que el definido en WeatherGatewayInterface, se considerará que cumple con ese protocolo.

Ya tenemos un primer paso resuelto, probar una abstracción en lugar de una implementación concreta. Ahora vamos a ver cómo podemos generar datos de prueba automáticamente para cubrir más casos, y más adelante no depender de la API real para hacer pruebas.

Generación de datos de prueba

Con las pruebas anteriores dependo de los inputs que yo mismo he definido, lo cual hará que mis pruebas estén llenas de sesgos. Para evitar eso, puedo usar herramientas de generación de datos.

Tenemos 2 caminos: definir qué datos aleatorios, o definir qué casos de prueba queremos generar a partir de especificaciones.

Faker

Para generar datos de prueba aleatorios como nombres, correos, direcciones, números de teléfono, etc; podemos usar la biblioteca faker.

from faker import Faker

fake = Faker()
name = fake.name()
email = fake.email()
password = fake.password()

Cada vez que llamamos a fake.name(), fake.email(), etc; obtenemos un valor diferente.

Supongamos que queremos probar algunas localizaciones aleatorias para nuestra función is_tropic. Podríamos usar faker para generar latitudes y longitudes aleatorias dentro de ciertos rangos:

from faker import Faker

fake = Faker()

def test_random_locations():
    latitude = fake.latitude()
    longitude = fake.longitude()
    result = is_tropic(latitude, longitude)

    # Aquí podríamos hacer aserciones basadas en la latitud y longitud generadas
    if -23.43691 <= latitude <= 23.43691:
        assert result #= True
    else:
        assert not result #= False

Nunca tendremos la certeza de que los datos generados aleatoriamente cubren todos los casos posibles, pero es mejor que los datos hardcodeados que nosotros mismos definimos.

Hypothesis para generar casos de prueba automáticamente basados en especificaciones

Con el código anterior solo validamos un solo caso aleatorio. Podríamos incluir un bucle con varias iteraciones para generar múltiples casos aleatorios. Sin embargo tenemos una herramienta mucho más potente para ello.

Con hypothesis podemos definir especificaciones para los datos de prueba que queremos generar, y la biblioteca se encargará de crear casos de prueba automáticamente basados en esas especificaciones. Va más allá de la generación de datos aleatorios, ya que incluyen invariantes (Nulos, vacíos, negativos, etc.) y casos límite (valores máximos, mínimos, etc.) que podrían romper nuestro código.

from hypothesis import given, strategies as st

@given(
    latitude=st.floats(min_value=-90, max_value=90),
    longitude=st.floats(min_value=-180, max_value=180)
)
def test_random_locations(latitude, longitude):
    result = is_tropic(latitude, longitude)

    if -23.43691 <= latitude <= 23.43691:
        assert result #= True
    else:
        assert not result #= False

Aunque en apariencia nos dé la sensación de que es similar a faker, aunque no lo veas está ejecutando decenas o incluso cientos de combinaciones de latitudes y longitudes. Hace el trabajo pesado por nosotros.

Wrappers

Supongamos que quiero probar el tiempo actual en New York, y yo sé que es soleado. Para ello creo la función get_current_weather que a su vez llama a datetime.now() para obtener la fecha y hora actuales.

def get_current_weather(location):
    current_time = datetime.now()
    weather = weather_service.get_weather(location, current_time)
    return weather

def test_get_current_weather():
    weather = get_current_weather("New York")
    assert weather.status == "sunny"

Hasta aquí todo bien. Lo ejecutas y funciona. ¿Y mañana?

En ocasiones debemos probar funcionalidades que dependen de elementos variables como:

  • Números aleatorios
  • Fechas y horas
  • Hashing
  • Acceso a recursos externos (como bases de datos o APIs)

Cualquier cosa que pueda hacer que las pruebas sean no deterministas o difíciles de reproducir. Y todas ellas, por supuesto, hacen que nuestras pruebas no siempre funcionen como nos gustaría.

Para sobrellevar esto, podemos usar un wrapper que nos permita fijar esos valores variables durante la ejecución de las pruebas.

Un wrapper es una función que envuelve a otra función para modificar su comportamiento de alguna manera. Se usa cuando queremos interactuar con una función de forma diferente a como lo haríamos normalmente.

Vamos a crear uno para datetime.now(). Supongamos que nosotros sabemos que el 1 de junio de 2024 a las 12:00 PM en New York fue soleado. Es una información muy valiosa que podemos usar para hacer pruebas. Lo que haremos es crear un wrapper que nos permita fijar la fecha y hora actuales durante la ejecución de los tests, para que siempre que llamemos a datetime.now() nos devuelva esa fecha y hora específica.

class FixedTimeProvider:
    """Test implementation - returns a fixed time."""

    def __init__(self, fixed_time: datetime):
        self._fixed_time = fixed_time

    def now(self) -> datetime:
        return self._fixed_time

Además, debemos modificar WeatherService para que reciba un time_provider como argumento en su constructor, y use ese proveedor de tiempo en lugar de llamar directamente a datetime.now():

class WeatherService:
    """Weather service that depends on TimeProvider for timestamp control."""

    def __init__(self, time_provider: TimeProvider):
        self._time_provider = time_provider

    def get_current_weather(self, city: str) -> Weather:
        current_time = self._time_provider.now()

        return Weather(
            status="sunny",
            temperature=25.0,
            checked_at=current_time
        )

Con estas piezas ya podemos empezar a construir nuestras pruebas de manera más controlada y confiable.

Creamos una fixture que nos devuelva una instancia de FixedTimeProvider con la fecha y hora que queremos fijar:

from datetime import datetime, timezone

import pytest

from time_provider import FixedTimeProvider
from weather_service import WeatherService


@pytest.fixture
def fixed_time():
    return datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)


@pytest.fixture
def time_provider(fixed_time):
    return FixedTimeProvider(fixed_time)


@pytest.fixture
def weather_service(time_provider):
    return WeatherService(time_provider)
  • La fixture fixed_time devuelve un objeto datetime con la fecha y hora que queremos fijar.
  • La fixture time_provider devuelve una instancia de FixedTimeProvider con la fecha y hora fija.
  • La fixture weather_service devuelve una instancia de WeatherService que usa el time_provider para obtener la fecha y hora actuales.

Hemos creado una instancia de WeatherService que depende de un time_provider para obtener la fecha y hora actuales. Cuando use datetime.now() dentro de WeatherService, en realidad estará llamando a FixedTimeProvider.now(), que nos devolverá la fecha y hora fija que hemos definido.

Para utilizarlo en nuestras pruebas, simplemente pasamos la fixture time_provider como argumento a la función de prueba.

def test_get_current_weather_sets_checked_at(weather_service, fixed_time):
    # When
    weather = weather_service.get_current_weather("New York")

    # Then
    assert weather.status == "sunny"
    assert weather.checked_at == fixed_time  # Clean and simple!


def test_weather_timestamp_is_controlled(weather_service):
    # When
    weather1 = weather_service.get_current_weather("New York")
    weather2 = weather_service.get_current_weather("London")

    # Then - Both have the same timestamp (as expected in tests)
    assert weather1.checked_at == weather2.checked_at

Este patrón de diseño se conoce como Dependency Injection (DI), y es muy útil para hacer que nuestro código sea más fácil de probar y mantener. Al inyectar dependencias en lugar de crearlas directamente dentro de nuestras funciones o clases, podemos cambiar fácilmente el comportamiento.

También dispones de una biblioteca llamada time-machine que hace exactamente lo mismo que nuestro wrapper, pero de manera más elegante y con soporte para código asíncrono. Puedes usarla así:

import datetime as dt
from zoneinfo import ZoneInfo
import time_machine

hill_valley_tz = ZoneInfo("America/Los_Angeles")


@time_machine.travel(dt.datetime(1985, 10, 26, 1, 24, tzinfo=hill_valley_tz))
def test_delorean():
    assert dt.date.today().isoformat() == "1985-10-26"

Pero añade una dependencia más a tu proyecto. Depende de la complejidad de tu proyecto, será más conveniente tu propio wrapper o usar time-machine.

Ahora vamos a ver otro ejemplo con un wrapper para números aleatorios. Supongamos que tenemos una función que genera un número aleatorio entre 1 y 10, y queremos probarla de manera determinista.

Empezamos creando un wrapper que nos permita fijar el número aleatorio durante la ejecución de los tests:

class FixedRandomProvider:
    """Test implementation - returns a fixed number."""

    def __init__(self, fixed_number: int):
        self._fixed_number = fixed_number

    def randint(self, a: int, b: int) -> int:
        return self._fixed_number

Es el momento de configurar las fixtures siguiendo el mismo patrón que antes. Creamos una fixture que nos devuelva una instancia de FixedRandomProvider con el número aleatorio que queremos fijar:

import pytest

from random_provider import FixedRandomProvider
from dice_game import DiceGame


@pytest.fixture
def dice_game_winning():
    """Game with a fixed winning roll (5)."""
    random_provider = FixedRandomProvider(5)
    return DiceGame(random_provider)


@pytest.fixture
def dice_game_losing():
    """Game with a fixed losing roll (2)."""
    random_provider = FixedRandomProvider(2)
    return DiceGame(random_provider)


def test_roll_dice_winning(dice_game_winning):
    # When
    result = dice_game_winning.roll_dice()

    # Then
    assert result.value == 5
    assert result.is_winning is True


def test_roll_dice_losing(dice_game_losing):
    # When
    result = dice_game_losing.roll_dice()

    # Then
    assert result.value == 2
    assert result.is_winning is False

Otra opción es usar la biblioteca dirty-equals, que nos permite hacer aserciones de igualdad flexibles.

from dirty_equals import IsUUID, IsDatetime, IsPositiveInt

def test_weather_response_shape(weather_provider_hot):
    recommender = OutdoorActivityRecommender(weather_provider_hot)
    result = recommender.recommend(40.4168, -3.7038)

    assert result["id"] == IsUUID()
    assert result["created_at"] == IsDatetime()
    assert result["temperature_celsius"] == IsPositiveInt()

La aserción pasa si el valor cumple la condición del tipo, no si coincide exactamente.

Mocking o stubbing para simular el comportamiento de dependencias externas sin necesidad de acceder a ellas realmente

En un ejemplo anterior hemos implementado una dependencia externa como OpenMeteoGateway que interactúa con la API de Open-Meteo. Es mala idea por diversas razones:

  • Es lento, ya que cada vez que se ejecuta la prueba, se hace una llamada cuya latencia depende de la red y del servidor externo.
  • Es frágil, ya que si la API cambia o deja de estar disponible, nuestras pruebas fallarán.
  • Es costoso, ya que algunas APIs externas tienen límites de uso o costos asociados a las llamadas.
  • Es poco confiable, no podemos garantizar que las respuestas siempre sean las mismas.

Entre muchos otros problemas. Por eso, en lugar de interactuar con la API real, podemos usar mocking o stubbing para simular el comportamiento de esa dependencia externa sin necesidad de acceder a ella realmente.

No solo de APIs externas vive el mocking. También podemos usarlo para simular el comportamiento de bases de datos, servicios en tiempo real, colas de mensajes, etc.

Leyendo datos

Vamos a suponer que tenemos una función que obtiene el clima actual en una ubicación determinada, y queremos probarla sin necesidad de hacer llamadas reales a la API de Open-Meteo.

Empezaremos definiendo una abstracción o protocolo que defina el contrato que deben cumplir las implementaciones que interactúan con la API de Open-Meteo:

@dataclass
class WeatherData:
    """Weather information from external system."""
    location: str
    temperature_celsius: float
    conditions: str
    humidity: int


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

Ahora, podemos crear una implementación concreta de esa interfaz que interactúe con la API de Open-Meteo, y otra implementación de prueba que devuelva datos fijos para nuestras pruebas.

Producción tendrá la implementación real:

class OpenMeteoWeatherProvider:
    def get_current_weather(self, latitude: float, longitude: float) -> WeatherData:
        # Lógica para interactuar con la API de Open-Meteo
        pass

Pero nosotros vamos a usar la implementación de prueba que devuelve datos fijos para nuestras pruebas:

class FixedWeatherProvider:
    def get_current_weather(self, latitude: float, longitude: float) -> WeatherData:
        # Lógica para devolver datos de prueba fijos
        pass

Python dispone de la biblioteca unittest.mock que nos permite crear objetos simulados (mocks) que imitan el comportamiento de objetos reales, y en su interior está la función create_autospec. Nos ayudará a crear un mock que respete la firma de la función o método que estamos simulando.

Por ejemplo, si queremos crear un mock de WeatherProvider, podemos usar create_autospec para asegurarnos de que el mock tenga la misma firma que la interfaz WeatherProvider:

provider = create_autospec(WeatherProvider)

La variable provider ahora es un mock de WeatherProvider. Esto significa que cualquier llamada a provider.get_current_weather debe tener los mismos parámetros y tipos de retorno que el método definido en WeatherProvider.

Podemos configurar el comportamiento de los métodos del mock usando return_value. Por ejemplo, podemos hacer que provider.get_current_weather devuelva un objeto WeatherData con datos de prueba específicos:

provider.get_current_weather.return_value = WeatherData(
    location="Madrid",
    temperature_celsius=30.0,
    conditions="Clear",
    humidity=40,
)

Ya podemos configurar los fixtures donde incluiremos mocks de WeatherProvider que devuelvan datos de prueba específicos para cada caso de prueba:

@pytest.fixture
def weather_provider_hot():
    """Mocked provider returning hot weather."""
    provider = create_autospec(WeatherProvider)
    provider.get_current_weather.return_value = WeatherData(
        location="Madrid",
        temperature_celsius=30.0,
        conditions="Clear",
        humidity=40,
    )
    return provider


@pytest.fixture
def weather_provider_mild():
    """Mocked provider returning mild weather."""
    provider = create_autospec(WeatherProvider)
    provider.get_current_weather.return_value = WeatherData(
        location="Barcelona",
        temperature_celsius=20.0,
        conditions="Partly cloudy",
        humidity=60,
    )
    return provider

Nos falta una última pieza. No tenemos nada que probar. El mock de WeatherProvider es solo un objeto simulado que devuelve datos de prueba, pero no tenemos ninguna función o clase que use ese proveedor para hacer algo con esos datos. Para eso, vamos a crear una clase OutdoorActivityRecommender que use el WeatherProvider para recomendar actividades al aire libre basadas en el clima:

class OutdoorActivityRecommender:
    """Recommends outdoor activities based on weather."""

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

    def recommend(self, latitude: float, longitude: float) -> ActivityRecommendation:
        weather = self._weather_provider.get_current_weather(latitude, longitude)

        if weather.temperature_celsius > 25:
            return ActivityRecommendation(
                activity="Swimming",
                reason=f"Perfect! It's {weather.temperature_celsius}°C",
            )
        elif weather.temperature_celsius > 15:
            return ActivityRecommendation(
                activity="Hiking",
                reason=f"Great weather at {weather.temperature_celsius}°C",
            )
        elif weather.temperature_celsius > 0:
            return ActivityRecommendation(
                activity="Skiing",
                reason=f"Snow weather! {weather.temperature_celsius}°C",
            )
        else:
            return ActivityRecommendation(
                activity="Stay indoors",
                reason=f"Very cold at {weather.temperature_celsius}°C",
            )

Ahora ya podemos simular el comportamiento de la API de Open-Meteo en nuestras pruebas.

def test_recommends_swimming_when_hot(weather_provider_hot):
    # Given
    recommender = OutdoorActivityRecommender(weather_provider_hot)

    # When
    result = recommender.recommend(40.4168, -3.7038)

    # Then
    assert result.activity == "Swimming"
    assert "30.0°C" in result.reason


def test_recommends_hiking_when_mild(weather_provider_mild):
    # Given
    recommender = OutdoorActivityRecommender(weather_provider_mild)

    # When
    result = recommender.recommend(41.3851, 2.1734)

    # Then
    assert result.activity == "Hiking"
    assert "20.0°C" in result.reason

El flujo de producción será:

  1. OutdoorActivityRecommender recibe un WeatherProvider como dependencia, en concreto OpenMeteoWeatherProvider.
  2. OpenMeteoWeatherProvider usa la función get_current_weather para obtener los datos climáticos.
  3. get_current_weather hace una llamada a la API de Open-Meteo para obtener el clima actual en la ubicación dada.
  4. La API de Open-Meteo devuelve una respuesta dependiendo de la latitud y longitud proporcionadas.

Ahora nuestro mock reemplaza al WeatherProvider real, devolviendo datos de prueba fijos cuando se llama a get_current_weather.

Este patrón de diseño es portable a otros lenguajes de programación y frameworks de testing. La idea es siempre la misma: abstraer las dependencias externas detrás de una interfaz o protocolo, y luego usar mocks o stubs para simular su comportamiento en las pruebas.

Para simplificarte el proceso con peticiones HTTP, puedes usar la biblioteca respx que hace exactamente lo mismo que el ejemplo anterior, pero de manera más elegante y con soporte para código asíncrono. Puedes usarla así:

import httpx
import respx

from httpx import Response

@respx.mock
def test_get_current_weather():
    # Given
    my_route = respx.get("https://api.open-meteo.com/v1/forecast").mock(
        return_value=Response(200, json={
            "current_weather": {
                "temperature": 30.0,
                "conditions": "Clear",
                "humidity": 40
            }
        })
    )

    # When
    response = httpx.get("https://api.open-meteo.com/v1/forecast?latitude=40.4168&longitude=-3.7038")
    data = response.json()

    # Then
    assert data["current_weather"]["temperature"] == 30.0
    assert data["current_weather"]["conditions"] == "Clear"
    assert data["current_weather"]["humidity"] == 40

No tengas miedo de trabajar con otros protocolos como WebSockets, gRPC, AMQP, etc.

Escribiendo/modificando datos

Probar operaciones de escritura o modificación requiere un enfoque diferente al de las lecturas: no basta con verificar que la función no falla, hay que confirmar que los datos se guardaron correctamente y con los valores esperados.

Siguiendo el mismo diseño desacoplado, definimos el protocolo del repositorio:

class WeatherRepositoryInterface(Protocol):

    def save(self, weather: WeatherData) -> None:
        pass

    def update(self, weather_id: int, weather: WeatherData) -> None:
        pass

En lugar de conectarnos a una base de datos real, creamos un stub (implementación falsa) que registra internamente las llamadas recibidas:

class FakeWeatherRepository:

    def __init__(self):
        self.saved: list[WeatherData] = []
        self.updated: list[tuple[int, WeatherData]] = []

    def save(self, weather: WeatherData) -> None:
        self.saved.append(weather)

    def update(self, weather_id: int, weather: WeatherData) -> None:
        self.updated.append((weather_id, weather))

A diferencia de un mock de create_autospec, el stub nos permite inspeccionar exactamente qué datos se escribieron, no solo si el método fue invocado.

Configuramos las fixtures con el stub y el servicio que lo usa:

@pytest.fixture
def weather_repository():
    return FakeWeatherRepository()

@pytest.fixture
def weather_saver(weather_repository):
    return WeatherSaver(weather_repository)

Y ya podemos probar tanto escrituras como actualizaciones:

def test_save_weather(weather_saver, weather_repository):
    # Given
    weather = WeatherData(
        location="Madrid",
        temperature_celsius=30.0,
        conditions="Clear",
        humidity=40,
    )

    # When
    weather_saver.save(weather)

    # Then
    assert len(weather_repository.saved) == 1
    assert weather_repository.saved[0].location == "Madrid"
    assert weather_repository.saved[0].temperature_celsius == 30.0


def test_update_weather(weather_saver, weather_repository):
    # Given
    weather = WeatherData(
        location="Madrid",
        temperature_celsius=35.0,
        conditions="Hot",
        humidity=20,
    )

    # When
    weather_saver.update(weather_id=1, weather=weather)

    # Then
    assert len(weather_repository.updated) == 1
    weather_id, updated_weather = weather_repository.updated[0]
    assert weather_id == 1
    assert updated_weather.temperature_celsius == 35.0

Si prefieres no escribir el stub a mano, puedes usar create_autospec y verificar que el mock fue llamado con los argumentos correctos:

from unittest.mock import create_autospec

@pytest.fixture
def weather_repository():
    return create_autospec(WeatherRepositoryInterface)

def test_save_weather_with_mock(weather_repository):
    # Given
    saver = WeatherSaver(weather_repository)
    weather = WeatherData(
        location="Madrid",
        temperature_celsius=30.0,
        conditions="Clear",
        humidity=40,
    )

    # When
    saver.save(weather)

    # Then
    weather_repository.save.assert_called_once_with(weather)

Usa el stub cuando necesites inspeccionar el contenido exacto de los datos escritos. Usa create_autospec cuando solo necesites confirmar que el método fue llamado con los argumentos correctos.

Snapshot testing para comparar la salida de una función con una versión previamente guardada

En la estrategia anterior, creábamos objetos falsos (mocks o stubs) para simular el comportamiento de dependencias externas. Funciona bien para casos pequeños y controlados, pero cuando la salida de la función que queremos probar es grande o compleja, escribir aserciones para cada campo puede ser tedioso.

Vamos a aprender a comparar la salida de una función con una versión previamente guardada, lo que se conoce como snapshot testing.

La estrategia es sencilla:

  1. Ejecutamos la función que queremos probar.
  2. Guardamos su salida en un archivo de snapshot.
  3. En ejecuciones posteriores, comparamos la salida actual con el snapshot guardado.

Cada vez que la salida cambie, volveremos a ejecutar los 2 primeros pasos para actualizar el snapshot. De esta manera, si el cambio es intencionado, actualizamos el snapshot. Si el cambio no es intencionado, el test fallará y nos alertará de que algo ha cambiado.

La biblioteca de referencia para esto con pytest es syrupy.

Veamos diferentes ejemplos de snapshot testing para distintos casos de uso.

Respuesta de una API

Supongamos que tenemos un endpoint que devuelve el clima actual para una ubicación. El test sería algo así:

import pytest

from app import app

@pytest.fixture
def client():
    return TestClient(app)


def test_get_weather_response(client):
    response = client.get("/weather/Madrid")

    assert response.status_code == 200
    assert response.json()['country'] == "Spain"

Para incluir snapshot testing, importamos SnapshotAssertion de syrupy.assertion y lo añadimos como argumento a la función de test:

# test_api.py
import pytest
from fastapi.testclient import TestClient
from syrupy.assertion import SnapshotAssertion # New

from app import app


@pytest.fixture
def client():
    return TestClient(app)


def test_get_weather_response(client, snapshot: SnapshotAssertion): # New
    response = client.get("/weather/Madrid")

    assert response.status_code == 200
    assert response.json() == snapshot # New
    assert response.json()['country'] == "Spain"

La primera vez que ejecutas el test fallará porque no existe ningún snapshot guardado. Para crearlo:

pytest --snapshot-update

Syrupy generará un archivo __snapshots__/test_api.ambr con la salida serializada:

# __snapshots__/test_api.ambr
# serializer version: 1
# name: test_get_weather_response
  dict({
    'conditions': 'Partly cloudy',
    'forecast': list([
      dict({
        'conditions': 'Sunny',
        'day': 'Monday',
        'temperature_celsius': 24.0,
      }),
      ...
    ]),
    'humidity': 65,
    'location': 'Madrid',
    'country': 'Spain',
    'temperature_celsius': 22.5,
    'wind_speed_kmh': 15.0,
  })
# ---

A partir de ahí, cualquier cambio en la respuesta hará fallar el test. Si el cambio es intencionado (añadir un campo, por ejemplo), actualizas el snapshot:

# Actualizar todos los snapshots
pytest --snapshot-update

# Actualizar solo el de un test concreto
pytest test_api.py::test_get_weather_response --snapshot-update

Actualiza el snapshot solo cuando estés seguro de que el cambio es correcto. Hacerlo a ciegas elimina la protección contra regresiones.

Un requisito importante: los valores variables como fechas o IDs deben fijarse antes de hacer snapshot testing. De lo contrario, el snapshot cambiará en cada ejecución. Para eso ya tienes los wrappers y la inyección de dependencias que vimos antes.

CSV

El snapshot testing también es útil para probar exportaciones a CSV. Supongamos que tenemos una función que genera un CSV con el histórico de temperaturas:

# weather_export.py
import csv
import io


def export_weather_to_csv(records: list[dict]) -> str:
    output = io.StringIO()
    writer = csv.DictWriter(output, fieldnames=["date", "location", "temperature_celsius", "conditions"])
    writer.writeheader()
    writer.writerows(records)
    return output.getvalue()

El test con snapshot:

from syrupy.assertion import SnapshotAssertion
from weather_export import export_weather_to_csv


def test_export_weather_to_csv(snapshot: SnapshotAssertion):
    # Given
    records = [
        {"date": "2024-06-01", "location": "Madrid", "temperature_celsius": 30.0, "conditions": "Sunny"},
        {"date": "2024-06-02", "location": "Madrid", "temperature_celsius": 28.5, "conditions": "Partly cloudy"},
        {"date": "2024-06-03", "location": "Madrid", "temperature_celsius": 25.0, "conditions": "Rainy"},
    ]

    # When
    result = export_weather_to_csv(records)

    # Then
    assert result == snapshot

Syrupy guardará el contenido exacto del CSV. Si alguien cambia el orden de las columnas, el separador o el formato de los valores, el test lo detectará.

Apuntes finales

A lo largo del artículo hemos visto varias técnicas y herramientas que puedes combinar según lo que necesites probar:

  • Fixtures para reutilizar objetos de prueba y mantener el código DRY.
  • Triangulación para romper el bloqueo inicial y construir la implementación de forma incremental.
  • Parametrización para cubrir muchos casos con un solo test.
  • Abstracciones y protocolos para desacoplar el código de sus dependencias externas, haciendo posible reemplazarlas en los tests.
  • Generación de datos con faker para datos realistas e hypothesis para encontrar casos límite que no se te habrían ocurrido.
  • Wrappers e inyección de dependencias para controlar elementos no deterministas como fechas, números aleatorios o accesos externos.
  • Mocks y stubs para simular el comportamiento de dependencias externas sin acceder a ellas realmente.
  • Snapshot testing para proteger la forma de respuestas o exportaciones sin escribir una aserción por cada campo.

Ninguna de estas técnicas excluye a las demás. Un test puede usar un mock para la API, un wrapper para la fecha y terminar con un snapshot. La clave es aplicar cada herramienta donde reduce fricción, no donde la añade.

El último consejo: una suite de tests que tarda 10 minutos en ejecutarse es una suite que nadie ejecuta. Mantén los tests rápidos, aislados y deterministas, y serán un aliado en lugar de una carga. Si aun así la suite crece, pytest-xdist la ejecuta en paralelo repartiendo los tests entre los núcleos disponibles:

# Usar todos los núcleos disponibles
pytest -n auto

# Usar un número concreto de workers
pytest -n 4

Con suites grandes, el ahorro de tiempo puede ser considerable sin cambiar ni una línea de los propios tests.

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

Will you buy me a coffee?

Comments

There are no comments yet.

Written by Andros Fenollosa

June 15, 2026

24 min of reading

You may also like

Visitors in real time

You are alone: 🐱