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.
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.
Conviene usar fixtures cuando varios tests necesitan una preparación similar y esa preparación aporta claridad.
No conviene usar fixtures para ocultar acciones importantes del comportamiento que la prueba quiere explicar.
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.
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.
Una fixture llamada datos, setup o objeto suele
aportar poca información. En TDD, los nombres ayudan a entender el ejemplo que estamos
escribiendo.
cuenta_con_saldo, cuenta_vacia o dos_cuentas_para_transferir.
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.
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.
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.
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.
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.
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.
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.
python -m pytest.La fixture es una mejora de diseño de las pruebas, no una obligación desde el primer test.
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.
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.
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.
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.
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.
Una fixture probablemente está acoplada al diseño interno cuando:
Refactorizá las pruebas de cuenta bancaria usando fixtures sin perder claridad.
crear_cuenta que reciba un saldo inicial opcional.cuenta.saldo.python -m pytest y confirmá que toda la suite siga en verde.conftest.py se usa solo para fixtures realmente compartidas.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.