🧪 Testing en Python con pytest
Instalación y estructura de tests, assert nativo, fixtures con scope, monkeypatch, tmp_path, mocking con unittest.mock, pytest.raises, markers, cobertura con pytest-cov y tests de API con TestClient.
Testing en Python con pytest
Escribir tests es la forma de confirmar que tu código hace lo que promete y de no romperlo al cambiarlo. pytest es el estándar de facto en Python: aprovecha el assert nativo, necesita poca configuración y tiene un sistema de fixtures para preparar y limpiar el entorno. Este artículo te lleva desde el primer test hasta una suite completa para una API real.
Instalación y estructura
pytest es una dependencia de desarrollo, no de producción:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytest pytest-cov
Convención de descubrimiento: archivos test_*.py, funciones test_*, clases Test* con métodos test_*.
mi_proyecto/
├── app/
│ ├── calculadora.py
│ └── api.py
├── tests/
│ ├── conftest.py
│ ├── test_calculadora.py
│ └── test_api.py
└── pyproject.toml
En pyproject.toml le indicas dónde buscar:
[tool.pytest.ini_options]
testpaths = ["tests"]
💡 Separar
app/detests/mantiene el código de producción limpio. Añadeappalsys.pathconpythonpath = ["."].
El assert nativo
A diferencia de JUnit (que tiene assertEqual, assertTrue, …), pytest usa el assert de Python y le añade contexto: al fallar, muestra los valores reales de cada lado.
# tests/test_calculadora.py
from app.calculadora import sumar
def test_sumar_basico():
assert sumar(2, 3) == 5
assert 2 in ordenar([3, 1, 2])
Si un assert falla, la salida es elocuente por sí sola:
> assert sumar(2, 3) == 6
E assert 5 == 6
E + where 5 = sumar(2, 3)
Fixtures: preparar y limpiar
Una fixture prepara datos u objetos. La pides como argumento y pytest la ejecuta antes, inyectándote su valor:
import pytest
@pytest.fixture
def usuario_valido():
return {"nombre": "ana", "email": "ana@example.com"}
def test_registro(usuario_valido):
assert registrar(usuario_valido)["id"] is not None
Scope
El scope controla cada cuánto se crea/destruye:
| Scope | Ciclo de vida | Cuándo usarlo |
|---|---|---|
function (default) |
por cada test | datos aislados y mutables |
class |
por clase | estado compartido en una clase |
module |
por archivo | conexiones caras a nivel de módulo |
session |
por toda la ejecución | configuración global, muy cara |
@pytest.fixture(scope="session")
def db_connection():
conn = create_connection("sqlite:///test.db")
yield conn
conn.close()
⚠️ Un scope amplio hace que varios tests compartan el objeto. Si un test lo modifica, el siguiente lo ve. Úsalo solo para recursos inmutables; para datos mutables usa
function.
Yield fixtures y conftest.py
Una fixture con yield separa setup (antes de yield) de teardown (después), que se ejecuta sí o sí:
@pytest.fixture
def cliente_db():
db = crear_db_temporal()
db.crear_tablas()
yield db # el test recibe `db`
db.drop() # limpieza al terminar
db.cerrar()
Las fixtures de conftest.py están disponibles para todos los tests del directorio sin importarlas:
# tests/conftest.py
import pytest
from app.database import get_engine
@pytest.fixture(scope="session")
def engine():
return get_engine("sqlite:///:memory:")
@pytest.fixture
def session(engine):
with engine.session() as s:
yield s
# tests/test_api.py — usa `session` sin importarla
def test_crear_usuario(session):
session.add(Usuario(nombre="luis"))
session.commit()
assert session.query(Usuario).count() == 1
parametrize: un test, muchas entradas
Ejecuta el test con cada combinación de argumentos:
import pytest
@pytest.mark.parametrize("a,b,esperado", [(2, 3, 5), (-1, 1, 0), (0, 0, 0)])
def test_sumar(a, b, esperado):
assert sumar(a, b) == esperado
Al combinar parámetros obtienes el producto cartesiano; un caso fallido se identifica en el nombre, p. ej. test_sumar[-1-1-0].
monkeypatch y capsys
monkeypatchcambia atributos, variables de entorno o funciones solo durante el test y los restaura al terminar.capsyscapturastdoutystderr.
import os
def saludar_con_env():
nombre = os.getenv("USUARIO", "invitado")
print(f"Hola, {nombre}")
return nombre
def test_saludo(monkeypatch, capsys):
monkeypatch.setenv("USUARIO", "ana")
resultado = saludar_con_env()
capturado = capsys.readouterr()
assert resultado == "ana"
assert "Hola, ana" in capturado.out
monkeypatch.setattr("app.cliente.requests.get", fn) reemplaza una función solo durante el test.
tmp_path: archivos temporales
tmp_path te da un directorio temporal único por test, que pytest limpia:
def test_escribir_y_leer(tmp_path):
archivo = tmp_path / "datos.txt"
escribir_archivo(archivo, "contenido")
assert leer_archivo(archivo) == "contenido"
assert archivo.exists()
Mocking con unittest.mock
Cuando tu código depende de algo que no quieres ejecutar de verdad (una API externa, el reloj, una DB), lo sustituyes por un mock. unittest.mock viene con Python y ofrece patch, MagicMock y Mock:
# app/cliente.py
import requests
def obtener_precio(simbolo):
r = requests.get(f"https://api.exchange.com/{simbolo}")
r.raise_for_status()
return r.json()["precio"]
from unittest.mock import patch, MagicMock
def test_obtener_precio():
respuesta_falsa = MagicMock() # responde a cualquier método
respuesta_falsa.json.return_value = {"precio": 123.45}
with patch("app.cliente.requests.get", return_value=respuesta_falsa):
precio = obtener_precio("AAPL")
assert precio == 123.45
patch como decorador
patch funciona igual como decorador; el mock llega como argumento:
@patch("app.cliente.requests.get")
def test_precio_ok(mock_get):
mock_get.return_value.json.return_value = {"precio": 50}
assert obtener_precio("MSFT") == 50
⚠️ Parchea el nombre en el lugar donde se usa (
app.cliente.requests.get), no donde se define. Sicliente.pyhaceimport requests, la referencia vive enapp.cliente.requests.
side_effect, excepciones y assert_called
side_effect: una función, un iterable (cada llamada devuelve el siguiente) o una excepción que se lanza;assert_called/assert_called_with: verifican llamadas y argumentos.
from unittest.mock import patch, MagicMock
def test_llamada_y_fallo():
with patch("app.cliente.requests.get") as mock_get:
mock_get.side_effect = RuntimeError("sin conexión")
try:
obtener_precio("AAPL")
except RuntimeError:
pass
mock_get.assert_called_once_with("https://api.exchange.com/AAPL")
def test_side_effect_secuencia():
with patch("app.cliente.requests.get") as mock_get:
mock_get.side_effect = [ # cada llamada devuelve uno
MagicMock(json=lambda: {"precio": 1}),
MagicMock(json=lambda: {"precio": 2}),
]
assert obtener_precio("A") == 1
assert obtener_precio("B") == 2
## Excepciones con pytest.raises
Para comprobar que el código lanza la excepción correcta:
```python
import pytest
def test_dividir_entre_cero():
with pytest.raises(ZeroDivisionError):
dividir(10, 0)
def test_error_validacion():
with pytest.raises(ValueError, match="email") as exc_info:
registrar_usuario({"email": "correo-invalido"})
assert exc_info.value.codigo == 400
Markers: skip, xfail y personalizados
Los markers etiquetan tests:
import pytest, sys
@pytest.mark.skip(reason="funcionalidad aún no implementada")
def test_funcion_futura():
...
@pytest.mark.xfail(reason="bug conocido")
def test_bug_conocido():
assert caracteristica_rota() == "esperado"
💡
xfailmarca el test como “se espera que falle”. Si de repente pasa, pytest te lo indica conXPASS, señal de que ya se arregló.
Los markers personalizados se registran en pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["lento: tests que tardan", "integracion: requieren servicios"]
Y los aplicas y filtras:
@pytest.mark.integracion
def test_contra_servicio_real():
...
pytest -m integracion # solo tests de integración
pytest -m "not lento" # excluye los lentos
Cobertura con pytest-cov
La cobertura te dice qué porcentaje de tu código se ejecutó. No garantiza calidad, pero muestra los huecos sin tests:
pytest --cov=app --cov-report=term-missing
Name Stmts Miss Cover Missing
-------------------------------------------------
app/api.py 40 5 87% 22-26, 40
app/cliente.py 18 10 44% 11-20, 33
-------------------------------------------------
TOTAL 58 15 74%
La columna Missing señala las líneas sin ejecutar. Configúrala en pyproject.toml con un umbral mínimo; con fail_under = 80, la suite falla si la cobertura baja:
Organizar los tests de una app real
tests/
├── conftest.py # fixtures compartidas
├── unit/ # rápidos, sin I/O, con mocks → cada commit
├── integration/ # DB de test, APIs → CI
└── e2e/ # flujo completo → antes de cada release
Agrúpalos por markers para ejecutarlos por separado en CI.
Ejemplo completo: tests para una API FastAPI con TestClient
# tests/conftest.py — envuelve la API (endpoints en app/main.py)
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="session")
def client():
with TestClient(app) as c:
yield c
# tests/integration/test_api.py
def test_crear_usuario(client):
r = client.post("/usuarios", json={"nombre": "ana", "email": "ana@example.com"})
assert r.status_code == 200
assert r.json()["nombre"] == "ana"
def test_leer_usuario_existente(client):
r = client.get("/usuarios/1")
assert r.status_code == 200
assert r.json()["email"] == "ana@example.com"
def test_leer_usuario_inexistente(client):
r = client.get("/usuarios/999")
assert r.status_code == 404
assert r.json()["detail"] == "no existe"
def test_datos_invalidos(client):
r = client.post("/usuarios", json={"nombre": "x"})
assert r.status_code == 422 # error de validación Pydantic
Con mocking para no depender de servicios externos:
from unittest.mock import patch
@patch("app.cliente.requests.get")
def test_dashboard_con_mock(client, mock_get):
mock_get.return_value.json.return_value = {"precio": 99}
r = client.get("/dashboard")
assert r.status_code == 200
assert r.json()["precio"] == 99
Cheatsheet
| Quieres… | Usas… |
|---|---|
| Primera suite | pip install pytest + archivos test_*.py |
| Comparar valores | assert a == b |
| Preparar datos | @pytest.fixture |
| Preparar y limpiar | fixture con yield |
| Compartir fixtures | conftest.py |
| Muchas entradas | @pytest.mark.parametrize |
| Cambiar algo en el test | monkeypatch |
| Capturar stdout | capsys |
| Archivo temporal | tmp_path |
| Simular dependencias | patch / MagicMock |
| Secuencia o error en mock | side_effect |
| Verificar llamadas | assert_called_once_with |
| Test de excepciones | with pytest.raises(...) |
| Saltar test | @pytest.mark.skip |
| Esperar un fallo | @pytest.mark.xfail |
| Medir cobertura | pytest --cov=app |
| Probar API FastAPI | TestClient(app) |
Para profundizar
- pytest: Getting Started: la guía oficial paso a paso.
- pytest fixtures: explicit, modular, scalable: el modelo de fixtures en profundidad.
- unittest.mock — Python docs: la referencia oficial del mocking.
- Real Python — pytest for beginners: tutorial completo con ejemplos.
- FastAPI — Testing: testing con TestClient.