Fixtures — preparación escrita una sola vez
Deja de repetir la preparación: escríbela una vez con @pytest.fixture, solicítala por nombre, obtén una copia nueva en cada test y limpia con yield incluso cuando un test falle.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes un pequeño carrito de compras, cart.py:
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self.lines)
def count(self):
return sum(quantity for _, _, quantity in self.lines)Y tres tests para él, test_cart.py:
from cart import Cart
def test_total():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.total() == 895.0
def test_count():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.count() == 4
def test_add_one_more():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sPasan, y son pesados de leer. Tres tests, nueve líneas de preparación, tres líneas que realmente comprueban algo. La preparación es la misma en cada uno, por lo que la única línea que difiere —la línea por la que existe el test— queda enterrada.
También pasa factura más adelante. Cuando Cart() empiece a necesitar un argumento, lo cambiarás en tres lugares, o en treinta. En el capítulo anterior, parametrize eliminó la repetición en las entradas de un test. Este capítulo elimina la repetición en su preparación.
Al terminar este capítulo podrás
- Escribir una fixture con
@pytest.fixturey solicitarla nombrándola como parámetro de un test - Explicar por qué cada test recibe su propia copia nueva y demostrarlo con una lista
- Construir una fixture a partir de otra
- Escribir una fixture con
yieldque limpie lo que crea y predecir el orden de preparación y desmontaje - Observar cómo se crean las fixtures con
--setup-show - Leer
fixture 'x' not foundy el error "called directly" - Elegir entre una fixture y una función auxiliar común
Requisitos previos: ejecutar un test sobre muchos casos con `parametrize`.
Antes de escribir el test
Las fixtures no son algo que agregas a los tests después. Surgen de un plan, y el plan va primero.
Qué se promete. Cart promete tres cosas: add() registra una línea y rechaza una cantidad inferior a 1 con ValueError; total() es la suma de precio × cantidad; count() es la suma de las cantidades. Ni archivos, ni red, ni estado global: hasta ahora nada que deba deshacerse.
Qué debe estar preparado. Lo mismo que en cada capítulo: un entorno virtual con pytest instalado y cart.py importable desde la carpeta en la que ejecutas pytest (coloca el archivo de test junto a él).
Qué casos. Anótalos y añade una columna que la gente suele olvidar: el estado desde el que parte el test:
| Caso | Estado inicial | Acción | Esperado | |---|---|---|---| | Total normal | pen × 3, bag × 1 | total() | 895.0 | | Conteo de artículos | pen × 3, bag × 1 | count() | 4 | | Una línea más | pen × 3, bag × 1 | add("notebook", 60.0, 2) | total() es 1015.0 | | Caso límite: nada en él | carrito vacío | total() | 0 | | Cantidad no válida | carrito vacío | add("pen", 15.0, 0) | ValueError |
Lee la segunda columna hacia abajo. Tres filas parten del mismo carrito lleno, dos de uno vacío. Cada valor repetido en esa columna es una fixture esperando a ser escrita: aquí, empty_cart y un cart construido sobre ella. Y una fila que parte de un estado que nadie más comparte no necesita ninguna fixture; construye el suyo propio.
Hazte una pregunta más sobre cada estado inicial: ¿crearlo cambia algo fuera del test? Un Cart nuevo no lo hace. Un código de descuento registrado en un diccionario global, una conexión abierta, un archivo escrito: esos sí lo hacen, y eso es lo que te indica que una fixture necesita limpieza (yield, más adelante en este capítulo).
Qué no probar. No escribas tests que demuestren que pytest construye una fixture nueva para cada test; esa es la promesa de pytest, y este capítulo lo muestra una vez para que puedas confiar en ello. Sí prueba tu propia limpieza cuando restaura el estado global: ese código es tuyo y puede estar equivocado.
El resto del capítulo convierte este plan en código.
Una primera fixture
Mueve la preparación a una función y coloca @pytest.fixture encima de ella:
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 15.0, 3)
c.add("bag", 850.0)
return c
def test_total(cart):
assert cart.total() == 895.0
def test_count(cart):
assert cart.count() == 4
def test_add_one_more(cart):
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sNada en los tests llama a cart(). Cada test simplemente tiene un parámetro llamado cart, y esa es toda la solicitud.
Antes de ejecutar un test, pytest examina los nombres de sus parámetros. Para cada nombre busca una fixture con ese nombre, la ejecuta y pasa lo que haya devuelto como argumento. El test dice qué necesita; no lo construye. Esta disposición tiene un nombre —inyección de dependencias—, pero el mecanismo no es más que "hacer coincidir el nombre del parámetro con el nombre de una fixture".
El test ahora se lee como una sola línea de intención. Y la preparación reside en un único lugar, por lo que un cambio en Cart() es un cambio en una sola función.
Cada test obtiene una copia nueva
test_add_one_more añade un cuaderno al carrito. ¿Vieron los tests que se ejecutaron después de él un carrito con un cuaderno? No, y vale la pena ver esto con claridad, porque es la propiedad que hace que las fixtures sean seguras.
Primero, la versión sin una fixture. Una lista al principio del módulo, compartida por dos tests:
ITEMS = ["pen", "bag"]
def test_add_item():
ITEMS.append("notebook")
assert len(ITEMS) == 3
def test_starts_with_two():
assert len(ITEMS) == 2pytest -q
.F [100%]
=================================== FAILURES ===================================
_____________________________ test_starts_with_two _____________________________
def test_starts_with_two():
> assert len(ITEMS) == 2
E AssertionError: assert 3 == 2
E + where 3 = len(['pen', 'bag', 'notebook'])
test_shared.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_shared.py::test_starts_with_two - AssertionError: assert 3 == 2
1 failed, 1 passed in 0.01sEl segundo test es correcto; falló por lo que hizo el primer test. Ejecútalo por su cuenta y pasará:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sUn test cuyo resultado depende de qué tests se ejecutaron antes es uno de los errores más costosos en una suite de tests, porque solo aparece a veces.
Ahora los mismos dos tests con la lista proveniente de una fixture. Un print dentro de la fixture muestra cuándo se ejecuta; -s le indica a pytest que no capture la salida impresa, para que podamos verla:
import pytest
@pytest.fixture
def items():
print("building a new list")
return ["pen", "bag"]
def test_add_item(items):
items.append("notebook")
assert len(items) == 3
def test_starts_with_two(items):
assert len(items) == 2pytest -q -s
building a new list
.building a new list
.
2 passed in 0.01s"building a new list" aparece dos veces. La función de la fixture se ejecutó una vez por cada test que la solicitó, y cada test obtuvo una lista que nadie más había tocado. Ese es el comportamiento predeterminado y tiene un nombre, alcance de función (function scope): el valor de la fixture vive tanto tiempo como una función de test. El capítulo ocho muestra cómo cambiar eso y por qué normalmente no deberías hacerlo.
Las fixtures pueden usar fixtures
Una fixture puede solicitar otra fixture exactamente de la misma manera en que lo hace un test: nombrándola como parámetro:
import pytest
from cart import Cart
@pytest.fixture
def empty_cart():
return Cart()
@pytest.fixture
def cart(empty_cart):
empty_cart.add("pen", 15.0, 3)
empty_cart.add("bag", 850.0)
return empty_cart
def test_new_cart_is_empty(empty_cart):
assert empty_cart.total() == 0
def test_total(cart):
assert cart.total() == 895.0pytest -q
.. [100%]
2 passed in 0.01scart se construye sobre empty_cart, de modo que un test puede solicitar el nivel de preparación que necesite. Ninguno de los dos tests llama a nada; pytest sigue los nombres, de test_total a cart y de cart a empty_cart, y los construye en el orden que exige esa cadena.
Verlo en acción: --setup-show
No tienes que agregar llamadas a print para averiguar qué fixtures se ejecutaron. --setup-show imprime cada preparación y desmontaje:
pytest -q --setup-show
SETUP F empty_cart
test_cart.py::test_new_cart_is_empty (fixtures used: empty_cart) .
TEARDOWN F empty_cart
SETUP F empty_cart
SETUP F cart (fixtures used: empty_cart)
test_cart.py::test_total (fixtures used: cart, empty_cart) .
TEARDOWN F cart
TEARDOWN F empty_cart
2 passed in 0.01sLéelo desde arriba. La F significa alcance de función (function scope): se construye para un test y se descarta después de él. empty_cart se prepara dos veces, una por test, que es la frescura de la sección anterior. Donde se usa cart, empty_cart se prepara primero, porque cart depende de ella. Y los desmontajes se producen en el orden opuesto a las preparaciones: lo último en construirse es lo primero en eliminarse.
(Si tienes plugins instalados, pueden aparecer líneas adicionales para sus propias fixtures. Esas son suyas, no tuyas).
Hasta ahora el desmontaje no hizo nada visible, porque no había nada que deshacer. Eso cambia a continuación.
yield: preparación y luego limpieza
Algunas preparaciones dejan algo atrás que debe deshacerse: una conexión que cerrar, un archivo que eliminar, una configuración global que restablecer. Para eso, una fixture usa yield en lugar de return:
import pytest
@pytest.fixture
def connection():
print("\nconnection: open")
conn = {"orders": []}
yield conn
print("connection: close")
def test_starts_empty(connection):
print("test: running")
assert connection["orders"] == []pytest -q -s
connection: open
test: running
.connection: close
1 passed in 0.01sTodo lo que está antes de yield es la preparación. El valor después de yield es lo que recibe el test. Luego la fixture se pausa —el test se ejecuta— y cuando el test termina, pytest reanuda la fixture después de yield, y ese resto es el desmontaje. (El . se encuentra delante de "connection: close" porque el resultado del test se imprime en el momento en que termina el test, justo antes de que comience el desmontaje).
La limpieza reside en la misma función que la preparación, unas pocas líneas más abajo. No puedes añadir una y olvidar la otra sin que sea evidente.
Con dos fixtures con yield, una dependiendo de la otra, el orden es el que sugería --setup-show:
import pytest
@pytest.fixture
def connection():
print("\n1. connection: open")
conn = {"orders": []}
yield conn
print("5. connection: close")
@pytest.fixture
def saved_order(connection):
print("2. saved_order: insert")
connection["orders"].append("A-100")
yield "A-100"
connection["orders"].remove("A-100")
print("4. saved_order: delete")
def test_order_is_saved(connection, saved_order):
print("3. test: running")
assert saved_order in connection["orders"]pytest -q -s
1. connection: open
2. saved_order: insert
3. test: running
.4. saved_order: delete
5. connection: close
1 passed in 0.01sLa preparación va desde la base de la cadena de dependencias hacia arriba; el desmontaje vuelve hacia abajo. Ese es el único orden que funciona: saved_order elimina su fila a través de la conexión, por lo que la conexión aún debe estar abierta cuando lo hace.
El desmontaje se ejecuta incluso si el test falla
¿Por qué tomarse la molestia de usar una fixture para la limpieza, cuando el test podría simplemente limpiar al final? Pruébalo. OPEN representa cualquier cosa que un test pueda dejar atrás: una conexión abierta, un archivo temporal, una configuración modificada:
OPEN = []
def test_broken():
OPEN.append("connection")
assert OPEN == ["A-100"]
OPEN.remove("connection")
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_body.py::test_broken - AssertionError: assert ['connec...
FAILED test_cleanup_in_body.py::test_nothing_left_open - AssertionError: asse...
2 failed in 0.01s(--tb=no oculta los tracebacks y conserva solo el resumen). Un error, dos fallos. Un assert que falla lanza una excepción, y lanzar una excepción termina el test en el acto, por lo que la línea de limpieza debajo de él nunca se ejecutó. La conexión se quedó en OPEN, y el siguiente test —que era perfectamente correcto— falló por lo que heredó. En una suite grande, ese segundo fallo es en el que pasarías una tarde entera investigando.
Ahora el mismo trabajo, con la limpieza después de un yield:
import pytest
OPEN = []
@pytest.fixture
def connection():
OPEN.append("connection")
yield "connection"
OPEN.remove("connection")
def test_broken(connection):
assert OPEN == ["A-100"]
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
F. [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_fixture.py::test_broken - AssertionError: assert ['con...
1 failed, 1 passed in 0.01sUn error, un fallo. El test roto se sigue reportando —la fixture no oculta nada—, pero su desorden no llegó al siguiente test.
Esa es la razón y la regla que se deriva de ella: la limpieza nunca va al final del cuerpo de un test. Va después del yield de una fixture.
¿Una fixture o una función normal?
Una fixture no es la única forma de compartir la preparación. Una función común —por ejemplo cart_with(*lines), que construye un carrito a partir de las líneas que reciba— también funciona, y el ejemplo completo que sigue a continuación utiliza una.
Aquí está la diferencia que lo decide: un test llama a una función auxiliar y puede pasarle argumentos. Un test no puede llamar a una fixture; solo puede nombrarla. Una fixture siempre produce lo mismo para cada test que la solicita.
Por lo tanto:
- Usa una fixture cuando muchos tests necesiten el mismo estado inicial, y especialmente cuando ese estado necesite limpieza después. Solo una fixture tiene un desmontaje garantizado.
- Usa una función auxiliar cuando cada test necesite un objeto ligeramente diferente: un carrito con estas líneas, un usuario con ese nombre. Para eso están los argumentos de las funciones.
Se combinan bien: una fixture puede llamar a una función auxiliar, como hace el siguiente ejemplo. (También hay una forma de crear una fixture que devuelva una función: el patrón "factory" en el capítulo doce. Todavía no lo necesitas).
Un ejemplo completo
Una tienda donde los códigos de descuento residen en un diccionario a nivel de módulo. Registrar un código modifica el estado global, por lo que un test que registre uno debe eliminarlo después.
shop.py:
DISCOUNTS = {}
def register_discount(code, percent):
DISCOUNTS[code] = percent
def remove_discount(code):
del DISCOUNTS[code]
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self, code=None):
amount = sum(price * quantity for _, price, quantity in self.lines)
if code is not None:
amount = amount * (100 - DISCOUNTS[code]) / 100
return round(amount, 2)test_shop.py:
import pytest
from shop import Cart, register_discount, remove_discount
def cart_with(*lines):
# A plain helper: it takes arguments, so each test can ask for its own cart.
cart = Cart()
for name, price, quantity in lines:
cart.add(name, price, quantity)
return cart
@pytest.fixture
def cart():
# The cart most tests share. Built fresh for every test that asks.
return cart_with(("pen", 15.0, 3), ("bag", 850.0, 1))
@pytest.fixture
def summer_code():
# Changes global state, so it must undo that change afterwards.
register_discount("SUMMER", 10)
yield "SUMMER"
remove_discount("SUMMER")
def test_total(cart):
assert cart.total() == 895.0
def test_summer_discount(cart, summer_code):
assert cart.total(summer_code) == 805.5
def test_code_is_gone_afterwards(cart):
with pytest.raises(KeyError):
cart.total("SUMMER")
def test_small_cart(summer_code):
cart = cart_with(("pen", 15.0, 2))
assert cart.total(summer_code) == 27.0
def test_zero_quantity_is_refused():
with pytest.raises(ValueError):
Cart().add("pen", 15.0, 0)pytest -q --setup-show
SETUP F cart
test_shop.py::test_total (fixtures used: cart) .
TEARDOWN F cart
SETUP F cart
SETUP F summer_code
test_shop.py::test_summer_discount (fixtures used: cart, summer_code) .
TEARDOWN F summer_code
TEARDOWN F cart
SETUP F cart
test_shop.py::test_code_is_gone_afterwards (fixtures used: cart) .
TEARDOWN F cart
SETUP F summer_code
test_shop.py::test_small_cart (fixtures used: summer_code) .
TEARDOWN F summer_code
test_shop.py::test_zero_quantity_is_refused .
5 passed in 0.01sVale la pena notar tres cosas.
En primer lugar, cada test pide únicamente lo que utiliza. test_small_cart quería un carrito diferente, por lo que no solicitó cart en absoluto; llamó a la función auxiliar con sus propias líneas, y --setup-show confirma que cart nunca se construyó para él. test_zero_quantity_is_refused solo necesita un carrito vacío y una llamada, por lo que no utiliza ninguna fixture; esa es la fila de "estado inicial que nadie más comparte" del plan.
En segundo lugar, test_code_is_gone_afterwards es un test de la fixture. Se ejecuta después de test_summer_discount y comprueba que SUMMER ya no existe. Para ver por qué es importante, cambia summer_code para que use return "SUMMER" en lugar de yield y la línea de limpieza:
pytest -q --tb=no
..F.. [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_code_is_gone_afterwards - Failed: DID NOT RAISE Key...
1 failed, 4 passed in 0.01sEl código se filtró fuera del test que lo registró y pasó al siguiente. Sin esa comprobación, la fuga habría pasado desapercibida.
En tercer lugar, la función auxiliar y la fixture colaboran. cart_with sabe cómo construir un carrito; la fixture cart decide qué carrito comparte la mayoría de los tests.
Cuando algo falla
fixture 'crat' not found
_________________________ ERROR at setup of test_empty _________________________
file .../test_typo.py, line 11
def test_empty(crat):
E fixture 'crat' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.Un nombre de parámetro no coincidió con ninguna fixture. O bien está mal escrito —aquí crat en lugar de cart, que está justo ahí en la lista—, o a la función le falta su línea @pytest.fixture, en cuyo caso no aparecerá en la lista. Observa la E y ERROR, no F ni FAILED: el test nunca llegó a comenzar.
Fixture "cart" called directly
Fixture "cart" called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.El test escribió cart() en su cuerpo. Una fixture se solicita nombrándola como parámetro, nunca llamándola. Si realmente quieres llamarla con tus propios argumentos, lo que necesitas es una función auxiliar.
ERROR at setup of test_total en lugar de un fallo La excepción se lanzó dentro de la fixture, antes de que se ejecutara el test —por ejemplo, ValueError: quantity must be at least 1: 0 debido a un add() incorrecto en la preparación—. Una E significa "la preparación se rompió"; una F significa "la afirmación era falsa". Mira primero en la fixture.
fixture function has more than one 'yield' Una fixture hace yield exactamente una vez. La preparación va antes del yield, la limpieza va después de él; para dos valores, haz yield de una tupla o escribe dos fixtures.
Un test pasa en solitario y falla junto a los demás Algo se está compartiendo entre los tests: una lista o un diccionario a nivel de módulo, o un elemento global que una fixture modificó y no restauró. Constrúyelo en una fixture, y si se trata de estado global, deshaz el cambio después de yield.
La limpieza no se ejecutó La limpieza está en el cuerpo del test después de un assert que falló, o bien la fixture usa return. Muévela a una fixture con yield, después del yield.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Ambos tests solicitan la misma fixture y ambos añaden elementos a la lista. ¿Qué dice la última línea de pytest -q?
import pytest
@pytest.fixture
def basket():
return []
def test_one(basket):
basket.append("pen")
assert basket == ["pen"]
def test_two(basket):
basket.append("bag")
assert basket == ["bag"]- A1 failed, 1 passed — test_two ve ['pen', 'bag']
- B1 passed, 1 error
- C2 passed
- D2 failed
Esto se ejecuta con pytest -q -s. Dejando a un lado los puntos y el resumen propios de pytest, ¿en qué orden se imprimen las tres líneas?
import pytest
@pytest.fixture
def db():
print("open")
yield "db"
print("close")
def test_query(db):
print("query")- Aopen query
- Bopen close query
- Cquery open close
- Dopen query close
Este test falla. ¿Dónde está el problema?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- A`len()` no se puede usar con una fixture
- BLa fixture necesita `yield`, no `return`
- CLa fixture debe devolver una tupla
- DEl test llama a la fixture directamente; en su lugar debería tomar `cart` como parámetro
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe library.py con una variable a nivel de módulo LOG = [] y una clase Library: add(title); borrow(title), que lance ValueError si el título no está en la estantería y, en caso contrario, lo elimine y añada "borrow <title>" a LOG; give_back(title); y available(), que devuelva una lista ordenada de los títulos en la estantería.
Luego escribe test_library.py. Planifícalo primero —una tabla con una columna de estado inicial, como al principio de este capítulo— y luego escribe:
- Una fixture
libraryque devuelva unaLibrarycon tres libros, y al menos tres tests que la utilicen —uno de los cuales tome prestado un libro, para que un test posterior pueda demostrar que la estantería vuelve a estar llena— - Una fixture
borrowedque dependa delibrary, tome prestado un libro y devuelva su título, con dos tests que la utilicen - Una fixture con
yieldclean_logque vacíeLOGantes del test y otra vez después de él; un test que la utilice y un test posterior que compruebeLOG == [] - Una función auxiliar normal
library_with(*titles), utilizada porlibraryy por un test que necesite una estantería vacía
Ejecuta pytest -q, luego pytest -q --setup-show, y compara cada línea con lo que esperabas.
Por último, rómpelo a propósito: en clean_log, reemplaza el yield y la línea posterior con return LOG. ¿Qué test falla? ¿Es el que utilizó la fixture?
Solución
Primero el plan:
| Caso | Estado inicial | Acción | Esperado | |---|---|---|---| | Tres libros en la estantería | Dune, Emma, Ulysses | available() | los tres, ordenados | | Tomar prestado elimina un libro | el mismo | borrow("Dune") | Emma, Ulysses | | Sin filtraciones entre tests | el mismo | — | siguen siendo tres | | No se puede tomar prestado dos veces | el mismo, con Emma prestado | borrow("Emma") | ValueError | | Devolver un libro | el mismo, con Emma prestado | give_back("Emma") | Emma en la estantería | | Se registra el préstamo | el mismo, LOG vacío | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | Registro limpio | después del test anterior | — | LOG == [] | | Caso límite: estantería vacía | sin libros | available() | [] |
Dos estados iniciales se repiten —la estantería de tres libros y la estantería con Emma prestado—, por lo que se convirtieron en fixtures. El registro vacío solo se necesita una vez, pero modifica el estado global que debe restaurarse después, y solo una fixture garantiza eso; por lo tanto, también es una fixture. La estantería vacía se necesita una vez y no deja nada atrás, por lo que utiliza la función auxiliar.
library.py:
LOG = []
class Library:
def __init__(self):
self.shelf = set()
def add(self, title):
self.shelf.add(title)
def borrow(self, title):
if title not in self.shelf:
raise ValueError(f"not on the shelf: {title}")
self.shelf.remove(title)
LOG.append(f"borrow {title}")
def give_back(self, title):
self.shelf.add(title)
def available(self):
return sorted(self.shelf)test_library.py:
import pytest
from library import LOG, Library
def library_with(*titles):
# Helper: each caller chooses its own shelf.
library = Library()
for title in titles:
library.add(title)
return library
@pytest.fixture
def library():
return library_with("Dune", "Emma", "Ulysses")
@pytest.fixture
def borrowed(library):
library.borrow("Emma")
return "Emma"
@pytest.fixture
def clean_log():
LOG.clear()
yield LOG
LOG.clear()
def test_three_books_on_the_shelf(library):
assert library.available() == ["Dune", "Emma", "Ulysses"]
def test_borrow_removes_the_book(library):
library.borrow("Dune")
assert library.available() == ["Emma", "Ulysses"]
def test_next_test_still_sees_three(library):
assert len(library.available()) == 3
def test_cannot_borrow_twice(library, borrowed):
with pytest.raises(ValueError):
library.borrow(borrowed)
def test_give_back_returns_it(library, borrowed):
library.give_back(borrowed)
assert borrowed in library.available()
def test_borrow_is_logged(library, clean_log):
library.borrow("Ulysses")
assert clean_log == ["borrow Ulysses"]
def test_log_is_empty_afterwards():
assert LOG == []
def test_empty_library():
library = library_with()
assert library.available() == []pytest -q
........ [100%]
8 passed in 0.01sCon --setup-show verás SETUP F library una vez por cada uno de los seis tests que la usan, borrowed preparándose después de library y desmontándose antes que ella, y ninguna línea de fixture para los dos últimos tests.
Y el experimento, con return LOG en lugar de yield:
pytest -q --tb=no
......F. [100%]
=========================== short test summary info ============================
FAILED test_library.py::test_log_is_empty_afterwards - AssertionError: assert...
1 failed, 7 passed in 0.01sLas decisiones, una por una:
librarydevuelve conreturn, no haceyield. UnaLibrarynueva no cambia nada fuera del test, por lo que no hay nada que deshacer.yieldes para fixtures que tienen algo que limpiar, no una elección de estilo.test_next_test_still_sees_threeestá ahí a propósito. Se ejecuta justo después de un test que tomó prestadoDune, y pasa porquelibraryse construyó de nuevo. En--setup-showpuedes contarlo:SETUP F libraryuna vez por test.borroweddevuelve el título. Los tests usanborroweden lugar de repetir"Emma", de modo que si la fixture cambia el libro que toma prestado, los tests la siguen. Y comoborroweddepende delibrary, ambos tests que solicitan las dos reciben la misma biblioteca: aquella de la que se tomó Emma.clean_loglimpia a ambos lados deyield. Antes, porque otros tests que llaman aborrow()—test_borrow_removes_the_book, la fixtureborrowed— ya han escrito enLOG; el test debe comenzar desde un estado conocido. Después, para que nada se filtre al siguiente test.- *El experimento falla en el otro test.*
test_borrow_is_loggedsigue pasando;test_log_is_empty_afterwards, que no hizo nada malo, falla. Así es como se ve siempre una limpieza faltante: el fallo aparece en otro lugar. Y es exactamente la razón por la que la solución incluye un test que comprueba la limpieza. library_withes una función auxiliar, no una fixture, porque el único test que la utiliza quiere una estantería diferente, y solo una función puede tomar argumentos.librarytambién la llama, por lo que el conocimiento de cómo se llena una biblioteca reside en un solo lugar.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz