pytest en Python: fixtures, parametrize, marks, monkeypatch y conftest

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP