En una prueba unitaria normalmente necesitamos preparar algo antes de ejecutar la unidad: un objeto, una lista de datos, una dependencia falsa, una configuración simple o un estado inicial conocido.
Cuando esa preparación aparece una sola vez, lo más claro suele ser escribirla dentro de la prueba. Pero cuando se repite en muchos casos, puede volverse ruidosa y dificultar la lectura. Ahí aparecen las fixtures y otras formas de preparación reutilizable.
Este tema explica cómo reutilizar preparación sin ocultar la intención de las pruebas. La meta no es eliminar toda repetición, sino evitar duplicación innecesaria mientras mantenemos pruebas claras, explícitas y fáciles de diagnosticar.
Una fixture es una pieza de preparación que una prueba puede usar. Puede crear datos, construir objetos, configurar una dependencia falsa o dejar listo un recurso necesario para ejecutar la prueba.
En pytest, una fixture se define con @pytest.fixture y luego se solicita como parámetro de la prueba.
import pytest
@pytest.fixture
def carrito_vacio():
return Carrito()
def test_carrito_vacio_tiene_cero_productos(carrito_vacio):
assert carrito_vacio.cantidad() == 0
pytest detecta que la prueba necesita carrito_vacio, ejecuta la fixture y entrega su resultado a la función de prueba.
Consideremos varias pruebas sobre un carrito. Sin reutilización, cada prueba puede repetir la creación del mismo objeto y los mismos datos.
def test_agregar_producto_incrementa_cantidad():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.cantidad() == 1
def test_carrito_con_producto_calcula_total():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.total() == 1000
La repetición no es grave en este ejemplo, pero puede crecer. Si preparar el carrito requiere varios pasos, cada prueba empieza a mezclar la intención principal con ruido de configuración.
Podemos extraer la preparación común a una fixture cuando varias pruebas necesitan el mismo punto de partida.
import pytest
@pytest.fixture
def carrito_con_mouse():
carrito = Carrito()
carrito.agregar("mouse", 1000)
return carrito
def test_carrito_con_producto_tiene_cantidad_uno(carrito_con_mouse):
assert carrito_con_mouse.cantidad() == 1
def test_carrito_con_producto_calcula_total(carrito_con_mouse):
assert carrito_con_mouse.total() == 1000
El nombre de la fixture debe comunicar el estado que entrega. carrito_con_mouse es más claro que un nombre genérico como datos o setup.
Una fixture mal usada puede hacer que la prueba sea más difícil de entender. Si la preparación contiene demasiadas cosas, el lector tendrá que saltar constantemente entre la prueba y la fixture para saber qué está ocurriendo.
Si un dato es esencial para entender el caso, muchas veces conviene dejarlo dentro de la prueba aunque se repita un poco.
No toda preparación debe convertirse en fixture. Si un objeto se usa en una sola prueba o si sus valores explican el caso, mantenerlo local suele ser más claro.
def test_cliente_con_1000_puntos_es_vip():
cliente = Cliente(puntos=1000)
assert cliente.es_vip() is True
El valor 1000 es parte central de la regla. Si lo ocultamos en una fixture llamada cliente, la prueba puede perder expresividad.
Las fixtures son útiles cuando la preparación es repetida, estable y secundaria respecto del comportamiento que se está probando.
Conviene usarlas para:
carrito_vacio o cuenta_con_saldo.Si la fixture no mejora la lectura, no hace falta usarla.
Las fixtures funcionan bien para objetos que deben comenzar siempre desde un estado conocido.
import pytest
class Cuenta:
def __init__(self, saldo):
self.saldo = saldo
def retirar(self, importe):
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
@pytest.fixture
def cuenta_con_saldo():
return Cuenta(500)
def test_retirar_disminuye_saldo(cuenta_con_saldo):
cuenta_con_saldo.retirar(200)
assert cuenta_con_saldo.saldo == 300
def test_retirar_mas_que_saldo_genera_error(cuenta_con_saldo):
with pytest.raises(ValueError):
cuenta_con_saldo.retirar(800)
Cada prueba recibe una cuenta preparada con saldo 500. El estado inicial queda claro por el nombre de la fixture.
Una fixture no debe generar dependencia accidental entre pruebas. Cada prueba debe comenzar con un estado propio y no depender de modificaciones hechas por otra prueba.
En pytest, una fixture simple se ejecuta por defecto una vez por prueba. Eso ayuda a mantener independencia.
@pytest.fixture
def lista_vacia():
return []
def test_agregar_un_elemento(lista_vacia):
lista_vacia.append("a")
assert lista_vacia == ["a"]
def test_otra_prueba_recibe_lista_vacia(lista_vacia):
assert lista_vacia == []
La segunda prueba no ve el elemento agregado por la primera. Esa independencia es fundamental para pruebas unitarias confiables.
Una fixture puede devolver datos base que cada prueba modifica según su caso. Esto es útil cuando un diccionario u objeto necesita muchos campos válidos, pero cada prueba solo cambia uno.
import pytest
@pytest.fixture
def pedido_valido():
return {
"cliente": "Ana",
"items": ["mouse"],
"total": 1000,
"pagado": True,
}
def test_pedido_valido_puede_confirmarse(pedido_valido):
assert puede_confirmarse(pedido_valido) is True
def test_pedido_sin_items_no_puede_confirmarse(pedido_valido):
pedido_valido["items"] = []
assert puede_confirmarse(pedido_valido) is False
La fixture entrega un pedido válido. La segunda prueba modifica solo el campo relevante para expresar el caso inválido.
Si una fixture devuelve estructuras mutables, como listas o diccionarios, cada prueba debe recibir una instancia nueva. De lo contrario, una modificación podría afectar otras pruebas.
Evita usar objetos globales mutables como datos de prueba compartidos:
PEDIDO_VALIDO = {"cliente": "Ana", "items": ["mouse"], "total": 1000}
def test_modifica_pedido():
PEDIDO_VALIDO["items"] = []
assert puede_confirmarse(PEDIDO_VALIDO) is False
Este estilo puede contaminar otras pruebas. Es preferible crear una copia nueva mediante fixture o función auxiliar.
No toda preparación reutilizable necesita ser una fixture. A veces una función auxiliar es más clara, especialmente si queremos crear variantes de datos dentro de la prueba.
def crear_pedido(cliente="Ana", items=None, total=1000):
if items is None:
items = ["mouse"]
return {
"cliente": cliente,
"items": items,
"total": total,
}
def test_pedido_sin_cliente_no_puede_confirmarse():
pedido = crear_pedido(cliente=None)
assert puede_confirmarse(pedido) is False
La función auxiliar permite mostrar explícitamente qué cambia en cada prueba. En muchos casos, esto comunica mejor que una fixture demasiado general.
Cuando los objetos de prueba crecen, puede aparecer un patrón llamado builder. Un builder ayuda a construir datos válidos por defecto y modificar solo lo importante para cada caso.
class PedidoBuilder:
def __init__(self):
self.datos = {
"cliente": "Ana",
"items": ["mouse"],
"total": 1000,
}
def sin_items(self):
self.datos["items"] = []
return self
def con_total(self, total):
self.datos["total"] = total
return self
def construir(self):
return dict(self.datos)
def test_pedido_sin_items_no_puede_confirmarse():
pedido = PedidoBuilder().sin_items().construir()
assert puede_confirmarse(pedido) is False
Este enfoque puede ser útil en proyectos grandes, pero no conviene introducirlo demasiado pronto. Para pruebas pequeñas, una función auxiliar suele alcanzar.
Las fixtures también pueden preparar dobles de prueba. Por ejemplo, un repositorio falso que permite probar una regla sin base de datos real.
import pytest
class RepositorioClientesFalso:
def __init__(self):
self.puntos_por_cliente = {}
def guardar_puntos(self, cliente_id, puntos):
self.puntos_por_cliente[cliente_id] = puntos
def obtener_puntos(self, cliente_id):
return self.puntos_por_cliente.get(cliente_id, 0)
@pytest.fixture
def repositorio_clientes():
return RepositorioClientesFalso()
def test_cliente_con_1000_puntos_accede_a_beneficio(repositorio_clientes):
repositorio_clientes.guardar_puntos(10, 1000)
servicio = ServicioBeneficios(repositorio_clientes)
assert servicio.puede_acceder(10) is True
La fixture prepara la dependencia falsa, pero la prueba mantiene visible el dato importante: el cliente tiene 1000 puntos.
En pytest, una fixture puede usar otra fixture. Esto permite componer preparación sin duplicar pasos.
import pytest
@pytest.fixture
def repositorio_clientes():
return RepositorioClientesFalso()
@pytest.fixture
def servicio_beneficios(repositorio_clientes):
return ServicioBeneficios(repositorio_clientes)
def test_cliente_sin_puntos_no_accede_a_beneficio(servicio_beneficios):
assert servicio_beneficios.puede_acceder(20) is False
Esta composición debe usarse con moderación. Si hay demasiadas capas de fixtures, la preparación puede volverse difícil de seguir.
pytest permite definir el alcance de una fixture mediante scope. Por defecto, el alcance es por función: la fixture se ejecuta para cada prueba.
@pytest.fixture(scope="function")
def carrito_vacio():
return Carrito()
Existen otros alcances, como módulo o sesión, pero en pruebas unitarias conviene preferir el alcance por función. Es el más simple y reduce el riesgo de compartir estado accidentalmente.
Usar fixtures más amplias puede ser razonable para recursos costosos e inmutables, pero debe hacerse con cuidado.
Algunas fixtures necesitan preparar algo antes de la prueba y limpiar después. En pytest puede hacerse con yield.
import pytest
@pytest.fixture
def archivo_temporal(tmp_path):
ruta = tmp_path / "datos.txt"
ruta.write_text("contenido")
yield ruta
# pytest elimina tmp_path al finalizar la prueba
def test_archivo_temporal_contiene_datos(archivo_temporal):
assert archivo_temporal.read_text() == "contenido"
En pruebas unitarias puras, muchas veces no necesitamos limpieza explícita. Pero cuando usamos recursos temporales, la fixture puede encapsular esa responsabilidad.
pytest incluye fixtures listas para usar. Algunas son útiles incluso en pruebas unitarias o pruebas pequeñas.
tmp_path: crea un directorio temporal para archivos de prueba.capsys: captura salida por consola.monkeypatch: modifica temporalmente variables, atributos o entorno.Estas fixtures evitan escribir preparación repetitiva y ayudan a que los cambios se reviertan al terminar la prueba.
Las fixtures pueden combinarse con pruebas parametrizadas. La fixture prepara el contexto común y los parámetros representan los casos.
import pytest
@pytest.fixture
def carrito():
return Carrito()
@pytest.mark.parametrize("producto, precio", [
("mouse", 1000),
("teclado", 2000),
])
def test_agregar_producto_incrementa_total(carrito, producto, precio):
carrito.agregar(producto, precio)
assert carrito.total() == precio
La fixture evita repetir la creación del carrito. Los parámetros dejan visibles los datos que cambian en cada ejecución.
El nombre de una fixture debe describir el estado o recurso que entrega. Un buen nombre reduce la necesidad de abrir su definición.
Ejemplos recomendables:
carrito_vaciocuenta_con_saldopedido_validorepositorio_clientes_falsoEjemplos poco claros:
setupdatosobjetofixture_generalUna fixture con nombre genérico obliga al lector a buscar su contenido para entender la prueba.
Esta tabla resume cuándo elegir preparación local, función auxiliar o fixture.
| Situación | Opción recomendada | Motivo |
|---|---|---|
| Preparación usada en una sola prueba. | Preparación local. | Es directa y fácil de leer. |
| Mismo objeto inicial en muchas pruebas. | Fixture. | Reduce repetición y nombra el estado inicial. |
| Datos válidos con pequeñas variaciones. | Función auxiliar o builder. | Permite mostrar qué cambia en cada caso. |
| Dependencia falsa repetida. | Fixture. | Centraliza la creación de un doble de prueba. |
| Preparación compleja que oculta la intención. | Revisar diseño. | Puede indicar una unidad demasiado difícil de probar. |
Al usar fixtures y preparación reutilizable, conviene evitar estos problemas:
setup o datos.Una fixture debe hacer que la prueba sea más clara. Si la vuelve más misteriosa, probablemente no está ayudando.
Las fixtures y la preparación reutilizable ayudan a mantener una suite de pruebas ordenada cuando muchos casos necesitan objetos, datos o dependencias similares. Bien usadas, reducen ruido y hacen que las pruebas se concentren en el comportamiento que verifican.
El criterio principal es la claridad. Una fixture no debería esconder el dato que explica el caso ni generar dependencia entre pruebas. Debe entregar un punto de partida limpio, con un nombre que comunique su propósito.
En el próximo tema veremos cómo refactorizar pruebas sin cambiar su intención, una habilidad importante para mejorar una suite existente sin perder la confianza que aporta.