El testing en Python tiene un framework casi universal: pytest. A diferencia de unittest, no requiere clases ni métodos especiales: basta con funciones que empiecen por test_. Pero su verdadero poder está en sus mecanismos de extensión: fixtures, parametrize, marks y monkeypatch.
Tests básicos sin clases
# test_calculadora.py
def sumar(a: float, b: float) -> float:
return a + b
def dividir(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("No se puede dividir por cero")
return a / b
# pytest descubre automáticamente funciones que empiezan por test_
def test_suma_basica():
assert sumar(2, 3) == 5
def test_suma_negativos():
assert sumar(-1, -1) == -2
def test_division_cero():
import pytest
with pytest.raises(ZeroDivisionError, match="No se puede dividir"):
dividir(10, 0)
# Ejecutar: # pytest test_calculadora.py -v # pytest test_calculadora.py -v -k "suma" # solo tests que contienen "suma"
Fixtures inyección de dependencias para tests
Una fixture es una función decorada con @pytest.fixture que produce datos o recursos para los tests. Pytest los inyecta automáticamente cuando el test declara el nombre de la fixture como parámetro.
import pytest
from dataclasses import dataclass
@dataclass
class Carrito:
items: list[str]
def agregar(self, item: str) -> None:
self.items.append(item)
def total_items(self) -> int:
return len(self.items)
@pytest.fixture
def carrito_vacio() -> Carrito:
"""Fixture: carrito sin items."""
return Carrito(items=[])
@pytest.fixture
def carrito_con_items() -> Carrito:
"""Fixture: carrito con items precargados."""
return Carrito(items=["manzana", "pera"])
def test_carrito_empieza_vacio(carrito_vacio):
assert carrito_vacio.total_items() == 0
def test_agregar_item(carrito_vacio):
carrito_vacio.agregar("naranja")
assert carrito_vacio.total_items() == 1
def test_carrito_inicial_tiene_items(carrito_con_items):
assert carrito_con_items.total_items() == 2
Fixtures con scope y teardown
import pytest
import sqlite3
@pytest.fixture(scope="function") # por defecto; se crea para cada test
def bd_temporal():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nombre TEXT)")
yield conn # aquí se ejecuta el test
conn.close() # teardown: se ejecuta aunque el test falle
@pytest.fixture(scope="module") # una sola instancia para todo el módulo
def cliente_api():
# Inicializa conexión costosa una vez
cliente = {"url": "https://api.test.com", "token": "abc123"}
yield cliente
# Limpieza al final del módulo
def test_insertar_usuario(bd_temporal):
bd_temporal.execute("INSERT INTO usuarios (nombre) VALUES (?)", ("Ana",))
cursor = bd_temporal.execute("SELECT nombre FROM usuarios")
assert cursor.fetchone()[0] == "Ana"
@pytest.mark.parametrize múltiples casos con un solo test
import pytest
def es_palindromo(texto: str) -> bool:
limpio = texto.lower().replace(" ", "")
return limpio == limpio[::-1]
@pytest.mark.parametrize("texto,esperado", [
("radar", True),
("ana", True),
("Anita lava la tina", True),
("python", False),
("", True),
("ab", False),
])
def test_palindromo(texto: str, esperado: bool):
assert es_palindromo(texto) == esperado
# pytest genera 6 tests individuales, uno por fila
import pytest
@pytest.mark.parametrize("a,b,resultado", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_suma(a, b, resultado):
assert a + b == resultado
monkeypatch parchear dependencias externas
monkeypatch permite sustituir temporalmente funciones, métodos, atributos o variables de entorno durante un test. Se restaura automáticamente al terminar.
import pytest
import requests
def obtener_usuario(user_id: int) -> dict:
respuesta = requests.get(f"https://api.ejemplo.com/usuarios/{user_id}")
respuesta.raise_for_status()
return respuesta.json()
def test_obtener_usuario(monkeypatch):
datos_falsos = {"id": 1, "nombre": "Ana", "email": "[email protected]"}
class RespuestaFalsa:
def raise_for_status(self): pass
def json(self): return datos_falsos
monkeypatch.setattr(requests, "get", lambda url: RespuestaFalsa())
resultado = obtener_usuario(1)
assert resultado["nombre"] == "Ana"
def test_variable_entorno(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
import os
assert os.environ["DATABASE_URL"] == "sqlite:///:memory:"
conftest.py compartir fixtures entre archivos
# conftest.py (en el directorio raíz de tests)
import pytest
@pytest.fixture(scope="session")
def config_global():
"""Disponible en todos los tests del proyecto."""
return {"env": "test", "debug": True, "timeout": 5}
@pytest.fixture
def datos_usuario():
return {"nombre": "Test User", "email": "[email protected]", "activo": True}
# test_users.py usa la fixture de conftest.py sin importarla
def test_usuario_activo(datos_usuario):
assert datos_usuario["activo"] is True
def test_entorno_test(config_global):
assert config_global["env"] == "test"
Marks útiles
import pytest
@pytest.mark.skip(reason="pendiente de implementar")
def test_funcionalidad_nueva():
assert False
@pytest.mark.skipif(condition=True, reason="solo en CI")
def test_solo_ci():
...
@pytest.mark.xfail(reason="bug conocido #123")
def test_bug_conocido():
assert 1 == 2 # se espera que falle; pytest lo marca como xfail, no como error
# Ejecutar solo tests marcados:
# pytest -m "not slow"
# pytest -m "integration"
La estructura recomendada: un conftest.py en la raíz con las fixtures compartidas; tests organizados por módulo; parametrize para cubrir casos extremos sin duplicar código; monkeypatch para aislar cualquier dependencia externa (red, filesystem, hora actual). Un buen conjunto de tests es tan importante como el código que prueban.
