Escribir pruebas con pytest y fixtures en Python
1. Descripción general
Cuando aumenta el número de pruebas, la parte de preparación de datos (crear clientes, pedidos, conexiones a la base de datos…) suele repetirse en todas partes. pytest resuelve esto con fixtures: una función que proporciona datos o recursos a la prueba y se encarga automáticamente de limpiarlos después de que la prueba termine.
Este artículo va desde tu primera prueba con pytest hasta fixtures con yield, scope, conftest.py, fixtures predefinidas y parametrización. Después de leerlo, podrás organizar tu conjunto de pruebas de Python de manera limpia, legible y con poco código repetido.
2. Instalación de pytest
Deberías instalar pytest en un entorno virtual:
python -m venv .venv
source .venv/bin/activate
pip install pytest
La estructura del proyecto que usaremos en este artículo es la siguiente:
shop/
├── shop.py
└── tests/
├── conftest.py
├── test_order.py
└── test_repository.py
pytest busca automáticamente archivos con nombres test_*.py o *_test.py. En cada archivo, ejecuta funciones que comienzan con test_.
Para importar shop desde la carpeta tests, añade esta configuración a pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
3. Tu primera prueba
Este es el código de negocio en shop.py. Los precios se representan en enteros (tipo int) para evitar errores de redondeo de float:
from dataclasses import dataclass, field
@dataclass
class Customer:
name: str
is_vip: bool = False
@dataclass
class Order:
customer: Customer
items: list[tuple[str, int, int]] = field(default_factory=list)
def add_item(self, sku: str, quantity: int, price: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.items.append((sku, quantity, price))
def total(self) -> int:
subtotal = sum(qty * price for _, qty, price in self.items)
return subtotal * 90 // 100 if self.customer.is_vip else subtotal
Los clientes VIP obtienen un 10% de descuento. Las pruebas para esta lógica van en tests/test_order.py:
import pytest
from shop import Customer, Order
def test_total_for_regular_customer():
order = Order(Customer("An"))
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_add_item_rejects_zero_quantity():
order = Order(Customer("An"))
with pytest.raises(ValueError, match="quantity must be positive"):
order.add_item("BOOK-01", 0, 50_000)
Solo necesitas dos cosas:
assertpuro de Python. No necesitasassertEqualni una librería de aserciones especial. pytest reescribe la sentenciaassertpara reportar errores de forma detallada.pytest.raisesverifica que un bloque de código lance la excepción correcta. El parámetromatches una expresión regular que se compara con el mensaje de la excepción.
Ejecuta las pruebas:
pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED [100%]
============================== 2 passed in 0.01s ===============================
Si cambias intencionalmente el valor esperado a 90_000, pytest mostrará ambos lados:
> assert order.total() == 90_000
E assert 100000 == 90000
E + where 100000 = total()
4. Fixtures básicas
4.1. Declaración y uso de fixtures
Ambas pruebas crean Customer("An") y Order. Cuando tienes docenas de pruebas, este código se repite constantemente. Muévelo a una fixture:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
def test_total_for_regular_customer(order):
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_empty_order_has_zero_total(order):
assert order.total() == 0
Cómo funciona:
@pytest.fixturemarca una función como una fixture.- La prueba declara un parámetro con el mismo nombre que la fixture. pytest llama a la fixture y pasa el valor devuelto a la prueba. Esta es inyección de dependencias por nombre.
- La fixture
orderrecibe la fixturecustomer. Las fixtures pueden depender de otras fixtures, pytest resuelve automáticamente el orden.
Por defecto, cada prueba recibe una nueva instancia. Por lo tanto, add_item en la primera prueba no cambia el order de la segunda prueba.
4.2. Factory fixture
A veces, una prueba necesita múltiples pedidos con parámetros diferentes. En lugar de devolver un objeto, una fixture puede devolver una función que crea objetos:
@pytest.fixture
def make_order():
def _make(name="An", is_vip=False, items=()):
order = Order(Customer(name, is_vip))
for sku, qty, price in items:
order.add_item(sku, qty, price)
return order
return _make
def test_vip_gets_ten_percent_discount(make_order):
regular = make_order(items=[("BOOK-01", 1, 200_000)])
vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])
assert regular.total() == 200_000
assert vip.total() == 180_000
Este patrón mantiene la preparación concisa mientras permite que cada prueba decida sus propios datos.
5. Setup y teardown con yield
Las fixtures son realmente útiles cuando trabajas con recursos que necesitan limpieza, como conexiones a bases de datos, archivos o servidores simulados. Añade un repositorio que usa SQLite a shop.py:
import sqlite3
class OrderRepository:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def save(self, customer_name: str, total: int) -> int:
cur = self.conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)",
(customer_name, total),
)
return cur.lastrowid
def count(self) -> int:
return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
Una fixture usa yield en lugar de return:
import sqlite3
import pytest
from shop import OrderRepository
@pytest.fixture
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
return OrderRepository(db_connection)
def test_save_order_returns_id(repository):
order_id = repository.save("An", 100_000)
assert order_id == 1
assert repository.count() == 1
La parte antes de yield es el setup. El valor después de yield se pasa a la prueba. La parte después de yield es el teardown y siempre se ejecuta, incluso si la prueba falla. No necesitas try/finally.
La bandera --setup-show muestra claramente el orden:
pytest tests/test_repository.py --setup-show
tests/test_repository.py
SETUP F db_connection
SETUP F repository (fixtures used: db_connection)
tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
TEARDOWN F repository
TEARDOWN F db_connection
La letra F significa scope function. El teardown se ejecuta en orden inverso al setup.
Nota: Si ocurre una excepción antes de
yield, la parte de teardown no se ejecutará. La razón es que la fixture aún no ha terminado de configurarse. Deberías mantener la parte de setup corta, o envolver manualmente contry/finallysi el setup tiene múltiples pasos que podrían fallar.
6. Alcance (scope) de fixtures
Crear recursos costosos (conexiones a bases de datos reales, iniciar contenedores…) para cada prueba hará que tu conjunto de pruebas sea lento. El parámetro scope determina cuánto tiempo vive una fixture:
| Scope | Se crea de nuevo cuando |
|---|---|
function (por defecto) |
cada prueba |
class |
cada clase de prueba |
module |
cada archivo de prueba |
package |
cada paquete |
session |
una sola vez para toda la ejecución |
Ejemplo de compartir una conexión para todo el archivo, pero limpiar datos después de cada prueba:
@pytest.fixture(scope="module")
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
yield OrderRepository(db_connection)
db_connection.execute("DELETE FROM orders")
def test_first_save(repository):
repository.save("An", 100_000)
assert repository.count() == 1
def test_second_save_starts_clean(repository):
repository.save("Binh", 50_000)
assert repository.count() == 1
db_connection se crea solo una vez. repository tiene scope function, así que limpia la tabla después de cada prueba. Gracias a eso, la segunda prueba sigue viendo la tabla vacía.
Hay una regla importante: una fixture solo puede usar fixtures con scope igual o más amplio. Si una fixture de scope session usa una fixture de scope function, pytest reporta un error:
ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.
7. Compartir fixtures a través de conftest.py
Las fixtures customer y make_order son útiles para muchos archivos de prueba. Colócalas en tests/conftest.py:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
pytest carga automáticamente conftest.py. Todas las pruebas en esa carpeta y subcarpetas pueden usar estas fixtures sin necesidad de importarlas. Puedes colocar conftest.py en múltiples niveles de directorios. Las fixtures en un nivel más cercano a la prueba sobrescriben las fixtures con el mismo nombre en niveles superiores.
Nota: No hagas
import conftesten tu archivo de prueba. Si lo haces, las fixtures pueden registrarse dos veces y causar comportamientos impredecibles.
Para ver qué pruebas usan qué fixtures y dónde se definen, ejecuta:
pytest --fixtures-per-test tests/test_order.py
8. Fixtures predefinidas
pytest proporciona muchas fixtures predefinidas y convenientes. Las tres más usadas son tmp_path, monkeypatch y capsys.
Supongamos que shop.py tiene dos funciones más:
import os
from pathlib import Path
def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
path.write_text("\n".join(lines), encoding="utf-8")
class PaymentService:
def __init__(self):
self.api_key = os.environ["PAYMENT_API_KEY"]
def charge(self, order: "Order") -> str:
print(f"Charging {order.total()} VND")
return "SUCCESS"
Las pruebas para ellas:
from shop import PaymentService, export_orders_csv
def test_export_orders_csv(tmp_path):
output = tmp_path / "orders.csv"
export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)
assert output.read_text(encoding="utf-8").splitlines() == [
"customer,total",
"An,100000",
"Binh,50000",
]
def test_payment_service_charges_order(monkeypatch, capsys, order):
monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
order.add_item("BOOK-01", 1, 75_000)
result = PaymentService().charge(order)
assert result == "SUCCESS"
assert capsys.readouterr().out == "Charging 75000 VND\n"
tmp_pathes unapathlib.Pathque apunta a un directorio temporal, exclusivo para cada prueba. Las pruebas no ensucian el directorio del proyecto.monkeypatchcambia variables de entorno, atributos o funciones dentro del alcance de una prueba. Todos los cambios se revierten automáticamente cuando la prueba termina. Además desetenv, a menudo usarásmonkeypatch.setattr(obj, "name", value).capsyscaptura el contenido impreso enstdoutystderr.readouterr()devuelve lo que se ha impreso desde el inicio de la prueba o desde la llamada anterior.
9. Parametrizar pruebas
9.1. @pytest.mark.parametrize
Cuando la misma lógica necesita verificarse con múltiples conjuntos de datos, no deberías copiar la prueba. Usa parametrize:
import pytest
@pytest.mark.parametrize(
("is_vip", "price", "expected"),
[
(False, 100_000, 100_000),
(True, 100_000, 90_000),
(True, 15, 13),
],
ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED
Cada conjunto de parámetros es una prueba separada, con IDs legibles gracias a ids. Ten en cuenta que puedes combinar fixtures (make_order) con parámetros. El caso vip-rounding-down documenta claramente el comportamiento de redondeo hacia abajo de //.
9.2. Fixture con parámetros
Si quieres que todas las pruebas que usen una fixture se ejecuten con múltiples variantes, parametriza la propia fixture:
@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
return Customer("An", is_vip=request.param)
def test_total_is_never_negative(any_customer):
order = Order(any_customer)
order.add_item("BOOK-01", 3, 10_000)
assert order.total() >= 0
request es una fixture especial. request.param contiene el valor actual. La prueba anterior se ejecuta automáticamente dos veces.
10. Errores comunes y cuándo no usar fixtures
- Nombre de fixture incorrecto. Si el nombre del parámetro no coincide con ninguna fixture, obtendrás el error
fixture 'oder' not found. pytest lista las fixtures disponibles justo debajo, léelo cuidadosamente para corregir. - Llamar a una fixture como función normal. Escribir
customer()dentro de una prueba reportará un errorFixture "customer" called directly. Decláralo como parámetro. - Scope amplio con datos mutables. Un
Ordercon scopemoduleque una prueba modifica hará que otra prueba falle. El resultado depende del orden de ejecución, muy difícil de depurar. Solo usa scope amplio para recursos costosos e inmutables, o limpia el estado en una fixture con scopefunctioncomo en la sección 6. - Abusar de
autouse=True. Una fixture que se aplica automáticamente a todas las pruebas es conveniente porque no necesitas declararla, pero el lector de la prueba no sabe dónde está. Solo úsalo para algo verdaderamente global, como bloquear la red. - Cadena de fixtures muy profunda. Cuando
orderdepende decustomer,customerdepende deaddress,addressdepende decountry…, el lector tiene que saltar por muchos archivos para entender la prueba. Con datos simples, crear directamente en la prueba o usar una factory fixture suele ser más legible.
Nota: Las fixtures son herramientas para preparar el entorno, no para contener lógica de verificación. Mantén los
asserten la función de prueba para que cada prueba sea capaz de comunicar por sí sola lo que está verificando.
11. Conclusión
Has cubierto los elementos centrales de pytest. El assert puro y pytest.raises se usan para verificar resultados. Las fixtures inyectadas por nombre eliminan el código de preparación repetido. yield proporciona setup/teardown seguro, mientras que scope ayuda a equilibrar velocidad y aislamiento. conftest.py comparte fixtures entre múltiples archivos. Las fixtures predefinidas como tmp_path, monkeypatch, capsys manejan archivos, entorno y output. Finalmente, parametrize y fixtures con params te permiten cubrir muchos casos sin duplicar pruebas.
Cuando tu conjunto de pruebas crece, deberías empezar con fixtures de scope function. Solo amplía el scope cuando hayas medido que la velocidad es realmente un problema.