18. Uso práctico de fixtures sin acoplar las pruebas al diseño interno

18.1 Objetivo del tema

En este tema vamos a usar fixtures de pytest para preparar datos y objetos frecuentes en las pruebas, sin hacer que esas pruebas dependan de detalles internos del código de producción.

Las fixtures son muy útiles en TDD porque reducen repetición y permiten concentrarse en el comportamiento. El riesgo aparece cuando una fixture conoce demasiado sobre cómo está implementado el objeto que estamos probando.

18.2 Qué es una fixture

Una fixture es una función que prepara algo que una o varias pruebas necesitan. Puede crear objetos, cargar datos de ejemplo, configurar un estado inicial o devolver una función auxiliar.

En pytest, una prueba pide una fixture recibiéndola como parámetro.

Archivo a crear o modificar: tests/test_cuenta.py

import pytest

from banco.cuenta import CuentaBancaria


@pytest.fixture
def cuenta():
    return CuentaBancaria()


def test_cuenta_nueva_tiene_saldo_cero(cuenta):
    assert cuenta.saldo == 0

La prueba no llama a la fixture directamente. pytest la ejecuta y entrega su resultado al test.

18.3 Cuándo conviene usar fixtures

Conviene usar fixtures cuando varios tests necesitan una preparación similar y esa preparación aporta claridad.

  • Crear una cuenta vacía.
  • Crear una cuenta con saldo inicial.
  • Preparar dos cuentas para una transferencia.
  • Construir datos válidos para una operación frecuente.

No conviene usar fixtures para ocultar acciones importantes del comportamiento que la prueba quiere explicar.

18.4 Repetición inicial sin fixture

Antes de extraer una fixture, observemos una repetición real. Estas pruebas crean una cuenta y le cargan saldo inicial.

Archivo a crear o modificar: tests/test_cuenta.py

def test_retirar_disminuye_el_saldo():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)

    cuenta.retirar(30)

    assert cuenta.saldo == 70


def test_retirar_registra_un_movimiento():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)

    cuenta.retirar(30)

    assert cuenta.movimientos[-1].tipo == "retiro"
    assert cuenta.movimientos[-1].importe == 30

La preparación se repite, pero todavía es fácil de entender. La fixture debe mejorar esa claridad, no esconderla.

18.5 Extraer una fixture simple

Podemos extraer una fixture que represente una cuenta con saldo disponible.

Archivo a crear o modificar: tests/test_cuenta.py

@pytest.fixture
def cuenta_con_saldo():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)
    return cuenta


def test_retirar_disminuye_el_saldo(cuenta_con_saldo):
    cuenta_con_saldo.retirar(30)

    assert cuenta_con_saldo.saldo == 70


def test_retirar_registra_un_movimiento(cuenta_con_saldo):
    cuenta_con_saldo.retirar(30)

    assert cuenta_con_saldo.movimientos[-1].tipo == "retiro"
    assert cuenta_con_saldo.movimientos[-1].importe == 30

El nombre cuenta_con_saldo comunica el estado inicial sin describir todos los detalles de construcción.

18.6 Evitar fixtures demasiado genéricas

Una fixture llamada datos, setup o objeto suele aportar poca información. En TDD, los nombres ayudan a entender el ejemplo que estamos escribiendo.

Una fixture debería tener un nombre que explique el estado útil para la prueba: cuenta_con_saldo, cuenta_vacia o dos_cuentas_para_transferir.

18.7 No acoplarse a atributos internos

Una fixture acoplada al diseño interno prepara el objeto modificando atributos que la clase no ofrece como comportamiento público.

Ejemplo a evitar:

@pytest.fixture
def cuenta_con_saldo():
    cuenta = CuentaBancaria()
    cuenta.saldo = 100
    return cuenta

Esta fixture es frágil. Si mañana el saldo deja de guardarse en un atributo llamado saldo, las pruebas fallarán aunque el comportamiento público siga siendo correcto.

18.8 Preparar usando comportamiento público

La alternativa es construir el estado usando métodos del dominio. Si una cuenta recibe saldo mediante depósitos, la fixture debería usar depositar.

Archivo a crear o modificar: tests/test_cuenta.py

@pytest.fixture
def cuenta_con_saldo():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)
    return cuenta

Esta preparación está alineada con el lenguaje del dominio y resiste mejor los cambios internos de implementación.

18.9 Fixture como fábrica

A veces necesitamos varias cuentas con saldos distintos. En lugar de crear muchas fixtures, podemos devolver una función auxiliar.

Archivo a crear o modificar: tests/test_cuenta.py

@pytest.fixture
def crear_cuenta():
    def _crear_cuenta(saldo_inicial=0):
        cuenta = CuentaBancaria()

        if saldo_inicial > 0:
            cuenta.depositar(saldo_inicial)

        return cuenta

    return _crear_cuenta

Esta fixture permite preparar distintos escenarios sin duplicar código y sin tocar atributos internos.

18.10 Usar la fábrica en una prueba

Ahora podemos crear el estado exacto que cada prueba necesita.

Archivo a crear o modificar: tests/test_cuenta.py

def test_transferencia_mueve_saldo_entre_cuentas(crear_cuenta):
    origen = crear_cuenta(saldo_inicial=100)
    destino = crear_cuenta()

    origen.transferir_a(destino, 40)

    assert origen.saldo == 60
    assert destino.saldo == 40

La prueba sigue mostrando lo importante: hay una cuenta origen con saldo, una cuenta destino vacía y una transferencia.

18.11 Mantener visible el acto principal

Una fixture no debería ocultar la acción que estamos probando. Si el test verifica una transferencia, la llamada a transferir_a debe verse dentro de la prueba.

Ejemplo a evitar:

@pytest.fixture
def transferencia_realizada(crear_cuenta):
    origen = crear_cuenta(saldo_inicial=100)
    destino = crear_cuenta()
    origen.transferir_a(destino, 40)
    return origen, destino


def test_transferencia_mueve_saldo_entre_cuentas(transferencia_realizada):
    origen, destino = transferencia_realizada

    assert origen.saldo == 60
    assert destino.saldo == 40

La prueba pasa, pero el comportamiento central quedó escondido en la preparación. Eso reduce la claridad del ejemplo.

18.12 Fixture para datos válidos

También podemos usar fixtures para representar datos válidos del dominio. Supongamos que aparece un objeto SolicitudTransferencia.

Archivo a crear o modificar: tests/test_transferencia.py

@pytest.fixture
def datos_transferencia_valida():
    return {
        "saldo_origen": 100,
        "importe": 40
    }

Esta fixture no impone cómo se implementa la transferencia. Solo expresa datos útiles para un escenario válido.

18.13 Fixtures y TDD

Durante el ciclo rojo, verde y refactor, conviene empezar con pruebas explícitas. Cuando aparece repetición clara, podemos extraer una fixture en la etapa de refactor.

  1. Escribimos una prueba roja con la preparación visible.
  2. Implementamos el mínimo código para llegar a verde.
  3. Si la preparación se repite, extraemos una fixture con nombre claro.
  4. Volvemos a ejecutar toda la suite con python -m pytest.

La fixture es una mejora de diseño de las pruebas, no una obligación desde el primer test.

18.14 Ubicación de las fixtures

Si una fixture solo se usa en un archivo, puede quedar en ese mismo archivo de pruebas. Si varias pruebas la necesitan, puede moverse a conftest.py.

Archivo posible: tests/conftest.py

import pytest

from banco.cuenta import CuentaBancaria


@pytest.fixture
def crear_cuenta():
    def _crear_cuenta(saldo_inicial=0):
        cuenta = CuentaBancaria()

        if saldo_inicial > 0:
            cuenta.depositar(saldo_inicial)

        return cuenta

    return _crear_cuenta

pytest detecta automáticamente las fixtures definidas en conftest.py dentro del árbol de pruebas.

18.15 No convertir conftest.py en un depósito de todo

Mover una fixture a conftest.py la vuelve disponible para muchas pruebas, pero también puede hacer más difícil saber de dónde sale cada dato.

Usá conftest.py para fixtures compartidas de verdad. Si una preparación solo sirve para una prueba o para un archivo, mantenela cerca de esas pruebas.

18.16 Scope de una fixture

Por defecto, una fixture se ejecuta una vez por cada prueba. Ese comportamiento suele ser el más seguro porque evita que una prueba contamine a otra.

En pruebas de dominio con objetos mutables, como cuentas bancarias, normalmente conviene mantener el scope por defecto.

Ejemplo explícito:

@pytest.fixture(scope="function")
def cuenta():
    return CuentaBancaria()

El scope function crea una cuenta nueva para cada test.

18.17 Riesgo de compartir estado mutable

Si una misma cuenta se compartiera entre varias pruebas, el resultado de una prueba podría depender de lo que hizo otra. Eso vuelve la suite frágil.

Ejemplo a evitar en este caso:

@pytest.fixture(scope="module")
def cuenta_compartida():
    return CuentaBancaria()

Para objetos que cambian de estado durante la prueba, es preferible que cada test reciba una instancia nueva.

18.18 Señales de acoplamiento

Una fixture probablemente está acoplada al diseño interno cuando:

  • Modifica atributos privados o protegidos.
  • Construye manualmente estructuras internas complejas.
  • Depende de nombres de campos que no forman parte del comportamiento público.
  • Oculta la acción principal que la prueba intenta verificar.
  • Debe cambiar cada vez que refactorizamos la implementación.

18.19 Ejercicio práctico

Refactorizá las pruebas de cuenta bancaria usando fixtures sin perder claridad.

  1. Creá una fixture crear_cuenta que reciba un saldo inicial opcional.
  2. Reescribí una prueba de retiro usando esa fixture.
  3. Reescribí una prueba de transferencia usando esa fixture.
  4. Verificá que ninguna prueba modifique directamente cuenta.saldo.
  5. Ejecutá python -m pytest y confirmá que toda la suite siga en verde.

18.20 Checklist del tema

  • Las fixtures reducen repetición sin ocultar el comportamiento principal.
  • La preparación usa métodos públicos del dominio.
  • Los nombres de fixtures describen estados relevantes.
  • Los objetos mutables se crean de nuevo para cada prueba.
  • conftest.py se usa solo para fixtures realmente compartidas.

18.21 Conclusión

Las fixtures son una herramienta muy práctica para mantener pruebas claras y sostenibles. En TDD deben aparecer como parte del refactor de la suite, cuando ya existe repetición real y el comportamiento está expresado con claridad.

En el próximo tema veremos cómo detectar pruebas frágiles durante el ciclo de TDD.