38. Fixtures y preparación reutilizable

38.1 Introducción

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.

38.2 Qué es una fixture

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.

38.3 El problema de repetir preparación

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.

38.4 Extraer una fixture simple

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.

38.5 Fixture no significa ocultar todo

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.

Una buena fixture elimina ruido repetido, pero deja visible en la prueba lo que importa para ese comportamiento.

Si un dato es esencial para entender el caso, muchas veces conviene dejarlo dentro de la prueba aunque se repita un poco.

38.6 Preparación local dentro de la prueba

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.

38.7 Cuándo conviene usar fixtures

Las fixtures son útiles cuando la preparación es repetida, estable y secundaria respecto del comportamiento que se está probando.

Conviene usarlas para:

  • Crear objetos que aparecen en muchas pruebas.
  • Construir dependencias falsas reutilizables.
  • Preparar datos base que luego cada prueba ajusta.
  • Evitar bloques largos de setup repetido.
  • Representar estados iniciales claros, como carrito_vacio o cuenta_con_saldo.

Si la fixture no mejora la lectura, no hace falta usarla.

38.8 Fixture para objetos con estado inicial

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.

38.9 Cada prueba debe recibir un estado limpio

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.

38.10 Fixtures que devuelven datos base

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.

38.11 Cuidado con modificar datos compartidos

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.

38.12 Funciones auxiliares como alternativa

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.

38.13 Builders de datos de prueba

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.

38.14 Fixtures para dependencias falsas

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.

38.15 Fixtures que dependen de otras fixtures

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.

38.16 Fixtures con alcance

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.

38.17 Setup y teardown

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.

38.18 Fixtures incorporadas del framework

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.

38.19 Fixtures y pruebas parametrizadas

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.

38.20 Nombres claros para fixtures

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_vacio
  • cuenta_con_saldo
  • pedido_valido
  • repositorio_clientes_falso

Ejemplos poco claros:

  • setup
  • datos
  • objeto
  • fixture_general

Una fixture con nombre genérico obliga al lector a buscar su contenido para entender la prueba.

38.21 Tabla de decisiones prácticas

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.

38.22 Errores frecuentes con fixtures

Al usar fixtures y preparación reutilizable, conviene evitar estos problemas:

  • Crear fixtures demasiado generales que sirven para muchas cosas distintas.
  • Ocultar datos importantes para entender la regla probada.
  • Compartir objetos mutables entre pruebas.
  • Usar nombres genéricos como setup o datos.
  • Construir muchas capas de fixtures dependientes entre sí.
  • Extraer preparación solo para eliminar una repetición pequeña y clara.
  • Usar fixtures con alcance amplio cuando el estado puede cambiar.
  • No revisar el diseño cuando preparar la unidad requiere demasiados pasos.

Una fixture debe hacer que la prueba sea más clara. Si la vuelve más misteriosa, probablemente no está ayudando.

38.23 Qué debes recordar de este tema

  • Una fixture prepara datos, objetos o dependencias para una prueba.
  • La preparación reutilizable debe reducir ruido sin ocultar la intención.
  • No toda repetición debe extraerse: a veces la preparación local es más clara.
  • Cada prueba debe recibir un estado limpio e independiente.
  • Las fixtures son útiles para objetos iniciales, dependencias falsas y recursos temporales.
  • Las funciones auxiliares y builders también son formas válidas de reutilizar preparación.
  • Los nombres de fixtures deben describir el estado que entregan.

38.24 Conclusión

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.