🔎 Buscar

🧪 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.

Wiki / Apuntes📖 Contenido

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/ de tests/ mantiene el código de producción limpio. Añade app al sys.path con pythonpath = ["."].

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

  • monkeypatch cambia atributos, variables de entorno o funciones solo durante el test y los restaura al terminar.
  • capsys captura stdout y stderr.
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. Si cliente.py hace import requests, la referencia vive en app.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"

💡 xfail marca el test como “se espera que falle”. Si de repente pasa, pytest te lo indica con XPASS, 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.