Kodokon kodokon.com

Pruebas con pytest: fixtures, parametrización, buenas prácticas

Estructura pruebas robustas con pytest: inyección de fixtures, teardown basado en yield, parametrización en forma de tabla y las trampas de los scopes amplios.

9 min · 3 preguntas

Abrir esta lección en Kodokon

pytest se apoya en dos ideas potentes: el descubrimiento por convención (archivos test_*.py, funciones test_*) y el simple assert, cuya introspección muestra los valores reales de ambos lados cuando falla - se acabó memorizar assertEqual y sus treinta variantes. Dos herramientas que conviene conocer desde ya: pytest.raises para comprobar que efectivamente se lanza una excepción, y pytest.approx para comparar números de coma flotante sin pelearse con el redondeo binario.

PYTHON
import pytest

def divide(a: float, b: float) -> float:
    return a / b

def test_nominal() -> None:
    assert divide(8, 4) == 2.0

def test_zero_division() -> None:
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

def test_floats() -> None:
    assert 0.1 + 0.2 == pytest.approx(0.3)
Sin approx, 0.1 + 0.2 == 0.3 es falso en coma flotante binaria.

Las fixtures son la inyección de dependencias de pytest: declara un parámetro que lleve el nombre de una fixture, y pytest la construye y la inyecta. El yield separa el setup del teardown - el código que va después se ejecuta incluso si la prueba falla. El parámetro scope (function por defecto, module, session) controla el tiempo de vida del objeto. Coloca las fixtures compartidas en un conftest.py: quedan disponibles en toda la carpeta, sin un solo import.

PYTHON
import sqlite3

import pytest

@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (name TEXT)")
    yield conn
    conn.close()

def test_insert(db) -> None:
    db.execute("INSERT INTO users VALUES ('Ada')")
    query = "SELECT name FROM users"
    rows = db.execute(query).fetchall()
    assert rows == [("Ada",)]
conn.close() se ejecuta después de la prueba, tanto si pasa como si falla.

@pytest.mark.parametrize convierte una prueba en una tabla de casos: cada tupla se convierte en una prueba independiente, con nombre propio y filtrable de forma individual. Apila varios decoradores parametrize para obtener el producto cartesiano de los parámetros. Dale a tus casos id legibles mediante pytest.param(..., id='...') - un informe de fallo debería entenderse sin abrir el archivo fuente.

PYTHON
import pytest

def slugify(text: str) -> str:
    return "-".join(text.lower().split())

@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("Hello World", "hello-world"),
        ("  Python  rocks ", "python-rocks"),
        pytest.param("", "", id="empty"),
    ],
)
def test_slugify(raw: str, expected: str) -> None:
    assert slugify(raw) == expected
Tres casos, tres pruebas distintas en el informe.
BASH
pytest -x --lf
pytest -k "slugify and not empty"
pytest -q --durations=5
--lf vuelve a ejecutar solo los fallos; --durations revela las pruebas lentas.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Cómo sabe pytest qué fixture debe suministrar a una prueba?
    • Gracias a un decorador @inject colocado sobre la prueba
    • Por el orden en que se declaran las fixtures en el archivo
    • Por la anotación de tipo del parámetro
    • Por el nombre del parámetro: si coincide con una fixture visible (del módulo o de conftest.py), pytest la construye y la inyecta
  2. En una fixture, ¿para qué sirve el código colocado después del yield?
    • Es el teardown: se ejecuta después de la prueba, incluso si la prueba falló
    • Nunca se ejecuta, el yield termina la fixture
    • Proporciona un valor de reserva si el setup falla
    • Solo se ejecuta si la prueba falla
  3. ¿Qué hace el comando pytest --lf?
    • Lista las fixtures disponibles en el proyecto
    • Vuelve a ejecutar solo las pruebas que fallaron en la última ejecución
    • Detiene la sesión en el primer fallo encontrado