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
OpenMeteoGatewayque debe cumplir conWeatherGatewayInterface? No lo sabe, pero no es necesario. En Python, el duck typing se encarga de eso. MientrasOpenMeteoGatewaytenga un métodoget_temperaturecon la misma firma que el definido enWeatherGatewayInterface, 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_timedevuelve un objetodatetimecon la fecha y hora que queremos fijar. - La fixture
time_providerdevuelve una instancia deFixedTimeProvidercon la fecha y hora fija. - La fixture
weather_servicedevuelve una instancia deWeatherServiceque usa eltime_providerpara 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á:
OutdoorActivityRecommenderrecibe unWeatherProvidercomo dependencia, en concretoOpenMeteoWeatherProvider.OpenMeteoWeatherProviderusa la funciónget_current_weatherpara obtener los datos climáticos.get_current_weatherhace una llamada a la API de Open-Meteo para obtener el clima actual en la ubicación dada.- 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:
- Ejecutamos la función que queremos probar.
- Guardamos su salida en un archivo de snapshot.
- 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
fakerpara datos realistas ehypothesispara 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.
- Bibliotecas de testing en Python
- Anatomía de una prueba
- Factory fixtures o crear objetos de prueba reutilizables
- Test-driven Development Triangulation
- Parametrización de pruebas para ejecutar el mismo test con diferentes conjuntos de datos
- Se testean abstracciones/protocolos/contratos, no implementaciones concretas
- Generación de datos de prueba
- Faker
- Hypothesis para generar casos de prueba automáticamente basados en especificaciones
- Wrappers
- Mocking o stubbing para simular el comportamiento de dependencias externas sin necesidad de acceder a ellas realmente
- Leyendo datos
- Escribiendo/modificando datos
- Snapshot testing para comparar la salida de una función con una versión previamente guardada
- Respuesta de una API
- CSV
- Apuntes finales
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Support me on Ko-fi
Comments
There are no comments yet.