pytest no es solo “el unittest más cómodo”. Es un framework de testing con un modelo de composición muy particular: en lugar de heredar de una clase base, declaras dependencias como parámetros de función y pytest las resuelve automáticamente. Eso cambia cómo piensas el setup de tus tests.
El mecanismo central es inyección de dependencias por nombre. Cuando pytest encuentra una función de test con parámetro db_client, busca una fixture con ese nombre registrada en el mismo archivo, en conftest.py, o en los plugins activos. No hay magia: es un registro explícito que se resuelve en tiempo de colección.
Las fixtures son funciones decoradas con @pytest.fixture. Pueden devolver un valor directamente o, si usas yield, dividir la función en setup (antes del yield) y teardown (después). Ese ciclo de vida se controla con el parámetro scope: function (default, se crea una vez por test), class, module (una por archivo) y session (una para toda la ejecución). El scope incorrecto es el error más frecuente cuando los tests se pisan entre sí: si una fixture session muta estado, todos los tests que la usan lo ven.
@pytest.mark.parametrize es la respuesta a “quiero correr el mismo test con distintas entradas sin duplicar código”. Cada tupla del listado genera un caso de test independiente en la salida de pytest, con su propio pase o fallo. Usarlo bien elimina docenas de funciones test_caso_1, test_caso_2 que son idénticas salvo los valores.
El fichero conftest.py es el punto de coordinación: pytest lo descubre automáticamente en cualquier directorio del árbol de tests y las fixtures que define están disponibles sin importar nada. Es el lugar correcto para fixtures de infraestructura compartida (base de datos de test, cliente HTTP mockeado, directorios temporales).
Si declaras una fixture en el mismo archivo de test en vez de en conftest.py y luego quieres reutilizarla en otro módulo, tendrás que importarla manualmente o moverla, así que la decisión de dónde vivir conviene tomarla pronto.
# conftest.py — disponible para todos los archivos de test del directorio
import pytest
from myapp.db import Database
@pytest.fixture(scope="session")
def db():
"""Conexión real a la BD de test. Se abre una vez por sesión."""
conn = Database("postgresql://localhost/test_db")
conn.apply_migrations()
yield conn
conn.drop_all_tables() # teardown: limpieza tras toda la suite
@pytest.fixture # scope="function" implícito
def clean_db(db):
"""Usa la conexión de sesión pero vacía las tablas antes de cada test."""
yield db
db.execute("TRUNCATE users, orders RESTART IDENTITY CASCADE")
# test_users.py
import pytest
from myapp.users import create_user, get_user, UserNotFoundError
# ---------------------------------------------------------------------------
# Parametrize: mismo test, distintos conjuntos de datos
# ---------------------------------------------------------------------------
VALID_EMAILS = [
("alice@example.com", True),
("bob+filter@domain.org", True),
("not-an-email", False),
("@nodomain.com", False),
]
@pytest.mark.parametrize("email, expected", VALID_EMAILS)
def test_email_validation(email, expected):
from myapp.users import is_valid_email
assert is_valid_email(email) == expected
# ---------------------------------------------------------------------------
# Fixtures con ciclo de vida
# ---------------------------------------------------------------------------
@pytest.fixture
def existing_user(clean_db):
"""Crea un usuario y devuelve su id. clean_db garantiza tabla vacía."""
user_id = create_user(clean_db, name="Alice", email="alice@example.com")
return user_id # el test recibe solo el id, no la conexión
def test_get_existing_user(clean_db, existing_user):
user = get_user(clean_db, existing_user)
assert user["name"] == "Alice"
def test_get_missing_user(clean_db):
with pytest.raises(UserNotFoundError, match="999"):
get_user(clean_db, user_id=999)
# ---------------------------------------------------------------------------
# Marks: agrupar y filtrar tests
# ---------------------------------------------------------------------------
@pytest.mark.slow # marca personalizada declarada en pytest.ini
def test_bulk_insert(clean_db):
users = [{"name": f"User {i}", "email": f"u{i}@x.com"} for i in range(1000)]
inserted = create_user(clean_db, batch=users)
assert inserted == 1000
# pytest.ini — necesario para que pytest no avise de marks desconocidas
[pytest]
markers =
slow: tests de integración o carga, excluidos en CI rápido
La separación entre db y clean_db es la decisión más importante del ejemplo. Abrir una conexión a la base de datos es caro; hacerlo una vez por sesión (scope="session") y delegar la limpieza a una fixture function da lo mejor de ambos mundos: velocidad del scope amplio, aislamiento del scope estrecho. clean_db recibe db como parámetro porque pytest respeta el grafo de dependencias entre fixtures.
existing_user devuelve solo el id, no la conexión. Esto no es casualidad: cada test debería recibir el recurso mínimo necesario. Si el test recibiera clean_db directamente desde existing_user lo estaría haciendo opaco; al declarar clean_db y existing_user como parámetros separados, la firma del test cuenta su historia.
@pytest.mark.parametrize con VALID_EMAILS como lista en el módulo tiene una ventaja práctica: puedes extender los casos sin tocar la función de test. Si el test falla en "@nodomain.com" verás exactamente esa entrada en el output de -v, no un índice numérico críptico.
Los marks personalizados (como slow) se vuelven útiles cuando tienes una suite mixta: pytest -k "not slow" en el pipeline de PR, pytest -m slow en el pipeline nocturno. Sin declararlos en pytest.ini, pytest lanzará PytestUnknownMarkWarning y, dependiendo de la configuración de CI, puede convertirse en un error.
Errores que debes conocer
Error: Usar scope="session" en una fixture que muta estado, provocando que tests posteriores fallen de forma no determinista dependiendo del orden de ejecución.
# ❌ Wrong
@pytest.fixture(scope="session")
def user_list():
return [] # todos los tests comparten la misma lista y se acumulan datos
def test_add_user(user_list):
user_list.append("alice")
assert len(user_list) == 1 # falla si otro test corrió antes
# ✅ Right
@pytest.fixture # scope="function": lista fresca por test
def user_list():
return []
def test_add_user(user_list):
user_list.append("alice")
assert len(user_list) == 1
Cambiar a scope="function" hace que cada test reciba su propia instancia; si el objeto es caro de crear, lo correcto es hacerlo inmutable o usar el teardown para resetearlo.
Error: Poner fixtures en un archivo de test normal y esperar que estén disponibles en otros módulos sin importarlas.
# ❌ Wrong
# test_orders.py
@pytest.fixture
def admin_user(): # solo visible dentro de test_orders.py
return {"role": "admin"}
# test_reports.py — admin_user no existe aquí, pytest lanza un error de fixture
def test_report_access(admin_user): ...
# ✅ Right
# conftest.py — sin imports necesarios en los archivos de test
@pytest.fixture
def admin_user():
return {"role": "admin"}
pytest descubre conftest.py automáticamente en el árbol de directorios; mover ahí las fixtures compartidas es la única forma de que estén disponibles sin configuración adicional.
Error: Ignorar el parámetro ids en parametrize, lo que genera nombres de caso como test_email_validation[not-an-email-False] en vez de algo legible cuando los datos son objetos complejos.
# ❌ Wrong
@pytest.mark.parametrize("payload, status", [
({"user": None}, 422),
({"user": "alice", "age": -1}, 400),
])
def test_create_endpoint(payload, status): ...
# output: test_create_endpoint[payload0-422], test_create_endpoint[payload1-400]
# ✅ Right
@pytest.mark.parametrize("payload, status", [
({"user": None}, 422),
({"user": "alice", "age": -1}, 400),
], ids=["missing_user", "negative_age"])
def test_create_endpoint(payload, status): ...
# output: test_create_endpoint[missing_user], test_create_endpoint[negative_age]
Con ids explícitos, el output de pytest -v y los mensajes de fallo en CI son inmediatamente legibles sin necesidad de descifrar la representación automática del objeto.
N° 157