Módulo 2: Arquitectura de fixtures: la columna vertebral
2. La fixture como pieza reutilizable
Descripción
En la lección anterior viste la columna vertebral completa como una foto. Ahora empezamos por el ladrillo: una fixture. Antes de componer, antes de la jerarquía de conftest.py, antes del scope, tienes que entender a fondo qué es una fixture por dentro y por qué merece ser la base del framework y no una función de ayuda cualquiera. Porque a primera vista se parecen: una fixture es una función con un decorador que devuelve un objeto, y una función de ayuda también devuelve un objeto. La diferencia no está en cómo se ve, sino en tres cosas que una fixture hace y una función normal no —y de esas tres, la que hoy importa es la más importante de todas: la garantía de instancia fresca.
Al terminar esta lección vas a saber escribir una fixture (@pytest.fixture sobre una función que arma algo), pedirla desde un test (nombrándola como parámetro), y explicar con una corrida real por qué eso es distinto de compartir una constante: cada test recibe su propia instancia recién armada, así que un test que modifica su escenario no contamina al siguiente. Vas a ver el bug de la constante compartida pasar de verdad, verlo desaparecer con una fixture, y usar pytest --setup-show para ver la fixture armándose de nuevo antes de cada test. Es el peldaño uno de la columna: sin este, componer no tiene sobre qué apoyarse.
Conexión con el módulo: esta lección es el ladrillo suelto; el resto del módulo lo ubica, lo compone y lo afina. La lección 3 mueve la fixture a conftest.py para que toda la suite la use sin importarla. La 4 le pone scope —lo que aquí es "fresca por test" es en realidad el default function, y verás que puedes cambiarlo—. La 5 compone: una fixture que pide otra. Aquí nos quedamos en una sola fixture, en un solo archivo, para que la atención esté en qué es y qué garantiza, no en dónde vive ni cómo se conecta. La frontera del módulo se respeta: nada de marcadores, nada de builders elaborados; solo el ladrillo y su garantía.
Un molde para hornear, no un pastel en el mostrador
Imagina una pastelería con dos maneras de darle un pastel a cada cliente que lo pide para decorarlo a su gusto.
La primera manera: hay un pastel ya horneado en el mostrador, y cada cliente lo decora ahí mismo. El primer cliente le pone fresas. El segundo llega, y el pastel ya trae las fresas del primero —no está limpio, está el pastel de alguien más—. Si el segundo esperaba un pastel en blanco, se lleva una sorpresa: el estado que dejó el cliente anterior sigue ahí. Y peor: qué pastel le toca a cada quien depende del orden en que llegaron. Un pastel compartido en el mostrador.
La segunda manera: la pastelería tiene un molde y una receta, y cuando un cliente pide un pastel, hornea uno nuevo con ese molde y se lo entrega recién salido, en blanco. El primer cliente le pone fresas a su pastel. El segundo recibe otro pastel, horneado con el mismo molde pero limpio, sin rastro de las fresas del primero. Cada cliente decora sobre un lienzo fresco, y el orden en que llegan no cambia nada. Un molde que produce un pastel nuevo por pedido.
Una fixture es el molde, no el pastel del mostrador. Cuando escribes @pytest.fixture sobre una función que arma un Calendar vacío, no estás poniendo un Calendar en el mostrador para que todos lo compartan: estás dándole a pytest una receta para hornear un Calendar nuevo cada vez que un test lo pida. El test que le agrega una reserva a su calendario no ensucia el del siguiente test, porque el siguiente recibe uno recién horneado. Esa es la garantía de instancia fresca, y es exactamente lo que una constante compartida —el pastel en el mostrador— no te da. Vas a ver el problema del mostrador y la solución del molde con código que corre.
Ejemplo trabajado: el bug del mostrador y el molde que lo arregla
Empecemos con el pastel en el mostrador: una constante mutable compartida entre dos tests. Es un patrón que se ve seguido porque parece inofensivo —"creo la lista una vez arriba y todos la usan"—.
# test_shared_constant.py — el pastel en el mostrador (NO hagas esto)
SHARED_BOOKINGS = []
def test_add_one_booking():
SHARED_BOOKINGS.append("bk-1")
assert len(SHARED_BOOKINGS) == 1
def test_starts_empty():
# este test asume una lista vacía, pero el anterior ya la contaminó
assert SHARED_BOOKINGS == []
SHARED_BOOKINGS es una lista creada una sola vez, arriba del archivo. El primer test le agrega "bk-1" y afirma que tiene un elemento —correcto—. El segundo test afirma que la lista está vacía. Corre los dos. Qué esperar:
.F [100%]
=================================== FAILURES ===================================
______________________________ test_starts_empty _______________________________
def test_starts_empty():
# este test asume una lista vacía, pero el anterior ya la contaminó
> assert SHARED_BOOKINGS == []
E AssertionError: assert ['bk-1'] == []
E
E Left contains one more item: 'bk-1'
E Use -v to get more diff
test_shared_constant.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_shared_constant.py::test_starts_empty - AssertionError: assert ['bk-1'] == []
1 failed, 1 passed in 0.02s
Léelo despacio, porque este bug es más traicionero de lo que parece. El segundo test falla no por nada que él haya hecho mal, sino por lo que el primero le dejó: la lista llegó con ['bk-1'] porque test_add_one_booking la modificó y el cambio persistió. El pastel del mostrador llegó con las fresas del cliente anterior. Y fíjate en el detalle venenoso: si corrieras test_starts_empty solo, pasaría —la lista estaría en blanco porque nadie la tocó—. Solo falla cuando corre después del otro. Eso es lo que hace a estos bugs tan difíciles: el test pasa o falla según el orden, según qué corrió antes, y eso es lo último que uno sospecha cuando busca la causa.
Ahora el molde: la misma idea, pero como fixture.
# test_fixture_fresh.py — el molde que hornea uno nuevo por test (haz esto)
import pytest
@pytest.fixture
def bookings():
# cada test recibe una lista NUEVA
return []
def test_add_one_booking(bookings):
bookings.append("bk-1")
assert len(bookings) == 1
def test_starts_empty(bookings):
# instancia fresca: el test anterior no lo afecta
assert bookings == []
Tres cambios, y cada uno importa. Primero, la lista ya no es una constante del módulo: es una fixture, una función bookings decorada con @pytest.fixture que devuelve una lista nueva. Segundo, cada test que la quiere la pide por su nombre, poniéndola como parámetro: def test_add_one_booking(bookings). Tercero —y es la consecuencia— cada test recibe el resultado de correr la fixture, y pytest corre la fixture de nuevo para cada test. Corre los dos. Qué esperar:
.. [100%]
2 passed in 0.00s
Los dos verdes. El mismo escenario que antes fallaba —un test agrega, el otro espera vacío— ahora funciona, y no porque cambiáramos lo que los tests afirman, sino porque cada uno recibe su propia lista recién horneada. test_add_one_booking le agrega "bk-1" a su lista; test_starts_empty recibe otra lista, vacía, sin rastro de la primera. El molde produjo dos pasteles, no compartió uno.
¿Cómo sé que de verdad la hornea dos veces? No hay que creerlo: pytest lo muestra. Corre la misma suite con --setup-show, la bandera que te enseña el andamio montándose:
collected 2 items
test_fixture_fresh.py
SETUP F bookings
test_fixture_fresh.py::test_add_one_booking (fixtures used: bookings) .
TEARDOWN F bookings
SETUP F bookings
test_fixture_fresh.py::test_starts_empty (fixtures used: bookings) .
TEARDOWN F bookings
============================== 2 passed in 0.00s ===============================
Ahí está la prueba. SETUP F bookings aparece dos veces —una antes de cada test—. La F es el scope (function: se arma por función, el default, tema de la lección 4). Cada SETUP es un pastel recién horneado; cada TEARDOWN es la fixture terminando su ciclo para ese test. pytest no reusó la lista: la creó de cero para test_add_one_booking, la desechó, y creó otra para test_starts_empty. El --setup-show es la ventana a la columna vertebral, y la vas a usar en todo el módulo para ver lo que las fixtures hacen por debajo.
Anatomía de una fixture
Ahora que viste una funcionar, nombremos sus partes con precisión, porque cada una reaparece en las lecciones que siguen.
El decorador @pytest.fixture. Es lo que convierte una función normal en una fixture. Sin él, bookings sería una función común y un test que la pidiera como parámetro fallaría con "fixture not found". El decorador es la etiqueta que le dice a pytest "esta función es una receta de andamio; cuando alguien pida algo con este nombre, córrela y entrégale el resultado". Requiere import pytest arriba del archivo.
El nombre de la fixture. Es el nombre de la función: bookings. Y es también el nombre por el que los tests la piden. Esta correspondencia —el parámetro del test debe llamarse igual que la fixture— es cómo pytest conecta al que pide con la receta. No hay registro, no hay lista central: pytest ve un parámetro llamado bookings, busca una fixture llamada bookings, la corre, y pasa el resultado. Por eso el nombre importa tanto y por eso conviene que diga qué entrega: focus_room, booking_service, pro_member.
El cuerpo: lo que arma. Es el código que construye el objeto —crear la lista, la Room, el Calendar—. Corre una vez por cada test que la pide (con el scope default). Aquí es donde vive el andamio que antes estaba copiado en cada test.
El valor que devuelve. Lo que la fixture entrega al test. Con return, la fixture entrega el objeto y termina. (Hay una segunda forma, con yield, que además limpia después; es la lección 6. Por ahora, return.) El test recibe ese valor en su parámetro y lo usa como cualquier variable.
Junta las cuatro partes y tienes la forma canónica de una fixture:
import pytest
from reservo.models import Room
@pytest.fixture
def focus_room(): # nombre: focus_room
return Room(id="focus", name="Focus", # cuerpo: arma la Room
capacity=4, hourly_cents=2500) # valor: la devuelve
def test_focus_costs_2500_per_hour(focus_room): # pide focus_room
assert focus_room.hourly_cents == 2500 # la usa
focus_room es una pieza de la columna de Reservo: la sala Focus con su tarifa de 2500 centavos la hora. Cualquier test que necesite esa sala la pide por su nombre y la recibe montada, fresca, sin repetir el constructor. Cuando el módulo termine, focus_room vivirá en un conftest.py y la usarán decenas de tests; hoy la tenemos en el mismo archivo para ver el ladrillo entero.
Las tres cosas que una fixture hace y una función de ayuda no
Vuelve a la pregunta del principio: si una fixture es "una función que devuelve un objeto", ¿por qué no usar una función de ayuda normal y llamarla desde cada test? Podrías escribir def make_focus_room(): return Room(...) y llamar make_focus_room() dentro de cada test. Funciona. Entonces, ¿qué te da la fixture que la función de ayuda no?
Uno: la garantía de instancia fresca la da pytest, no tú. Con una función de ayuda, tú tienes que acordarte de llamarla dentro de cada test para obtener un objeto nuevo; si por comodidad la llamas una vez y guardas el resultado en una constante, vuelves al pastel del mostrador. Con la fixture, la instancia fresca es automática: pytest corre la receta por cada test que la pide, sin que tú hagas nada. La garantía deja de depender de tu disciplina.
Dos: se descubre sin importar (vía conftest.py). Esta es la lección 3, pero conviene adelantarla porque es una diferencia enorme. Una función de ayuda hay que importarla en cada archivo que la use (from helpers import make_focus_room). Una fixture puesta en conftest.py está disponible en todos los tests de esa carpeta sin un solo import. Cuando tienes cincuenta archivos de tests, "sin imports" contra "un import por archivo" es la diferencia entre una columna vertebral y un enredo de dependencias.
Tres: se compone declarando otras fixtures como parámetros. Esta es la lección 5, el corazón del módulo. Una fixture puede pedir otra fixture simplemente nombrándola como parámetro, y pytest arma el grafo entero. Una función de ayuda tendría que llamar a mano a las otras funciones de ayuda, pasando los resultados. La composición declarativa —"pido calendar y payments, pytest me los da armados"— es lo que te deja construir el booking_service compuesto sin escribir el orden de armado. Ninguna función de ayuda te da eso.
Tres diferencias, y las tres apuntan al mismo lugar: una función de ayuda te deja reutilizar código, pero una fixture te da infraestructura. La primera te ahorra teclear; la segunda sostiene la suite. Por eso las fixtures, y no las funciones de ayuda, son la columna vertebral.
Errores comunes
Olvidar el @pytest.fixture y ver "fixture not found" (de decorador ausente). Qué pasa: alguien escribe la función que arma el escenario, la pide como parámetro en un test, y pytest falla con fixture 'focus_room' not found. Por qué pasa: sin el decorador, focus_room es una función normal; pytest no la reconoce como fixture y, al ver el parámetro del test, no encuentra ninguna fixture con ese nombre. Cómo detectarlo: el mensaje es literal —fixture 'X' not found— y suele listar las fixtures que sí existen; si la tuya no está en esa lista, o no tiene el decorador o está en otro archivo fuera de alcance. Cómo corregirlo: pon @pytest.fixture justo encima de la función y asegúrate de tener import pytest. Es el error más común de principiante con fixtures y el mensaje te lleva de la mano.
Llamar la fixture como función en vez de pedirla como parámetro (de confundir receta con plato). Qué pasa: alguien escribe room = focus_room() dentro del test, y pytest lanza un error raro o la fixture no se comporta como espera. Por qué pasa: viene del hábito de las funciones de ayuda, donde llamas make_focus_room(). Pero una fixture no se llama: se pide, poniéndola como parámetro, y pytest la corre por ti. Cómo detectarlo: si ves paréntesis después del nombre de una fixture dentro de un test —focus_room()—, está mal usada. Cómo corregirlo: quita los paréntesis y pon focus_room como parámetro de la función de test; recibes el resultado ya armado en la variable focus_room. (La excepción es la fixture-factory de la lección 7, que devuelve una función que sí llamas —pero ahí la fixture sigue pidiéndose como parámetro, y lo que llamas es su resultado—.)
Volver al pastel del mostrador con un objeto mutable "para ahorrar" (de recaída en la constante). Qué pasa: alguien tiene una fixture correcta, nota que armar el objeto "cuesta", y para ahorrar lo guarda en una constante del módulo o sube el scope sin pensarlo, y de golpe los tests empiezan a contaminarse entre sí según el orden. Por qué pasa: la instancia fresca parece un desperdicio cuando el objeto es caro de armar. Cómo detectarlo: si un test pasa solo pero falla cuando corre después de otro (o si el orden cambia el resultado), sospecha estado compartido. Cómo corregirlo: para cualquier objeto que un test pueda modificar —una lista, un Calendar, un dict—, la instancia fresca no es un desperdicio, es la garantía que te salva del bug del mostrador. Si el armado es de verdad caro y el objeto es de solo lectura, entonces subir el scope es legítimo, pero es una decisión consciente con su trade-off, y es justo el tema de la lección 4. En la duda, fresca por test.
Ejercicios
Ejercicio 1 — Convierte la constante en fixture. Aquí hay un archivo de tests que comparte un Calendar como constante del módulo. El segundo test falla porque el primero le agregó una reserva. Conviértelo para que cada test reciba un Calendar fresco. (Recuerda: Calendar() crea uno vacío; calendar.add(booking) le agrega una reserva.)
from datetime import datetime
from reservo.calendar import Calendar
from reservo.models import Booking
CAL = Calendar() # compartido: el pastel del mostrador
some_booking = Booking(id="bk-1", room_id="focus", member_id="m-ana",
start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 12),
status="confirmed", price_cents=7500)
def test_add_makes_it_non_empty():
CAL.add(some_booking)
assert len(CAL.confirmed_for_room("focus")) == 1
def test_new_calendar_is_empty():
assert CAL.confirmed_for_room("focus") == []
Ver solución
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.models import Booking
some_booking = Booking(id="bk-1", room_id="focus", member_id="m-ana",
start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 12),
status="confirmed", price_cents=7500)
@pytest.fixture
def calendar():
return Calendar() # el molde: uno nuevo por test
def test_add_makes_it_non_empty(calendar):
calendar.add(some_booking)
assert len(calendar.confirmed_for_room("focus")) == 1
def test_new_calendar_is_empty(calendar):
assert calendar.confirmed_for_room("focus") == []
Tres cambios, los tres de esta lección: la constante CAL se volvió una fixture calendar con @pytest.fixture; cada test la pide como parámetro en vez de leer la global; y así cada uno recibe un Calendar recién horneado. Ahora test_add_makes_it_non_empty le agrega la reserva a su calendario, y test_new_calendar_is_empty recibe otro calendario vacío —los dos pasan sin importar el orden—. Si corrieras --setup-show, verías SETUP F calendar dos veces, una por test.
Ejercicio 2 — Predice el --setup-show. Sin correr nada, dibuja la salida de --setup-show para esta suite (una fixture pro_member, dos tests que la piden). ¿Cuántas veces aparece SETUP F pro_member? ¿Por qué?
import pytest
from reservo.models import Member
@pytest.fixture
def pro_member():
return Member(id="m-ben", name="Ben", tier="pro")
def test_pro_tier(pro_member):
assert pro_member.tier == "pro"
def test_pro_name(pro_member):
assert pro_member.name == "Ben"
Ver solución
SETUP F pro_member aparece dos veces —una antes de cada test que la pide—, así:
SETUP F pro_member
test_x.py::test_pro_tier (fixtures used: pro_member) .
TEARDOWN F pro_member
SETUP F pro_member
test_x.py::test_pro_name (fixtures used: pro_member) .
TEARDOWN F pro_member
Por qué dos veces: el scope default de una fixture es function (la F en la salida), que significa "se arma de nuevo para cada función de test que la pide". Dos tests piden pro_member, así que la receta corre dos veces y cada test recibe su propio Member. En este caso el Member es de solo lectura —ningún test lo modifica—, así que compartir uno no causaría bugs; pero el default seguro es fresca por test, y solo se sube el scope como decisión consciente (lección 4). El punto del ejercicio es que leas --setup-show con soltura: cada SETUP F es una instanciación, y contar cuántas hay te dice cuántas veces corrió la receta.
Ejercicio 3 — Fixture o función de ayuda. Para cada situación, di si conviene una fixture o basta una función de ayuda normal, y por qué: (a) armar la sala Focus, que treinta tests de distintos archivos necesitan; (b) una función que, dado un price_cents, devuelve el string "$25.00" para un mensaje de error legible; (c) armar un Calendar con tres reservas que varios tests modifican de formas distintas.
Ver solución
- (a) Fixture. Un escenario que treinta tests de distintos archivos necesitan es el caso de manual para una fixture en
conftest.py: se define una vez, se pide por nombre, y está disponible en todos esos archivos sin un solo import (lección 3). Una función de ayuda obligaría a importarla en cada archivo. Es infraestructura, no un cálculo suelto. - (b) Función de ayuda. Convertir
2500en"$25.00"es una transformación pura de un valor en otro: no arma un escenario, no necesita instancia fresca, no se compone con otras fixtures. Es una función normal (def format_cents(cents): ...) que llamas donde la necesites. Meterla en una fixture sería forzar la herramienta. (Estas utilidades de formato y aserción son, de hecho, el tema del módulo 5.) - (c) Fixture, y con cuidado. Un
Calendarcon tres reservas que varios tests modifican grita fixture por la garantía de instancia fresca: si fuera una constante compartida, el primer test que agregue o cancele una reserva contaminaría a los demás (el bug del mostrador de esta lección). Cada test necesita su propioCalendarpoblado y fresco. Es exactamente el tipo de escenario mutable para el que la instancia fresca existe.
La regla que destila el ejercicio: usa una fixture cuando arme un escenario reutilizable —sobre todo si es mutable o si muchos tests lo necesitan—; usa una función de ayuda para transformaciones puras de un valor en otro. La primera es infraestructura de la suite; la segunda es una utilidad. Confundirlas lleva a fixtures que no deberían serlo y a funciones de ayuda que deberían serlo.
Resumen y siguiente paso
En esta lección pusiste el primer ladrillo de la columna: una fixture. Viste qué es por dentro —@pytest.fixture sobre una función que arma un objeto y lo devuelve— y cómo un test la usa: la pide por su nombre, como parámetro, y pytest corre la receta y le entrega el resultado. Y viste la garantía que la hace valer la pena, con la analogía del molde contra el pastel del mostrador: una fixture hornea una instancia nueva por test, así que un test que modifica su escenario no contamina al siguiente. Lo comprobaste con código que corre —el bug de la constante compartida fallando por el orden, la fixture arreglándolo, y --setup-show mostrando SETUP F una vez por test como prueba de la instancia fresca—.
Nombraste la anatomía —decorador, nombre, cuerpo, valor devuelto— y las tres cosas que separan una fixture de una función de ayuda: la instancia fresca la garantiza pytest, se descubre sin importar, y se compone declarando otras fixtures como parámetros. Las tres apuntan a lo mismo: una fixture no es reutilización de código, es infraestructura de la suite.
Antes de avanzar deberías poder: escribir una fixture con @pytest.fixture y pedirla desde un test; explicar, con la analogía del molde, por qué cada test recibe una instancia fresca y por qué eso evita la contaminación por orden; y leer --setup-show para contar cuántas veces se arma una fixture.
Lo que sigue es sacar la fixture del archivo de tests y darle su hogar de verdad. En la lección 3 vas a conocer conftest.py, el archivo especial donde pytest busca fixtures y las ofrece a todos los tests de una carpeta sin que importes nada —y su jerarquía: el conftest.py de la raíz, visible en toda la suite, contra uno por carpeta, visible solo en su rama—. Ahí la fixture deja de ser un ladrillo suelto en un archivo y pasa a ser parte de la columna que sostiene la suite entera.
Recursos
- Cómo usar fixtures en pytest — la guía oficial. La sección "Fixtures are requested by test functions" explica exactamente lo que viste aquí: un test pide una fixture nombrándola como parámetro, y pytest la corre y le pasa el resultado. Está en inglés.
@pytest.fixtureen la referencia de la API — la ficha del decorador que convierte una función en fixture, con todos sus parámetros (scope,autouse,params,name). Hoy solo usaste la forma sin argumentos; los demás llegan en las lecciones que siguen.pytest --setup-show— la bandera que te muestra cadaSETUPyTEARDOWN, la que usaste para comprobar que la fixture se hornea de nuevo por cada test. Es tu ventana a la columna vertebral durante todo el módulo.