Alcance de fixtures y conftest.py — compartir la preparación de forma segura
Construye una preparación lenta una sola vez y compártela: los cinco alcances, --setup-show, el peligro del estado compartido y ScopeMismatch, conftest.py para fixtures sin imports y sobrescrituras por directorio, lo que cuesta autouse y el orden en que se crean las fixtures.
¿Cómo quieres aprender?
El problema que vamos a resolver
Las fixtures del capítulo anterior se construían desde cero para cada test. Ese es el valor predeterminado correcto, hasta que lo que se construye resulta costoso. Aquí tienes una conexión a una base de datos que tarda medio segundo en abrirse, tal como ocurriría con el protocolo de enlace (handshake) con un servidor real:
test_users.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT)")
return conn
@pytest.fixture
def db():
conn = connect()
yield conn
conn.close()
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
assert name == "ravi"pytest -q --durations=3 informa a dónde se fue el tiempo:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
0.50s setup test_users.py::test_insert_one
0.50s setup test_users.py::test_names_are_text
3 passed in 1.65sTres tests, tres conexiones, un segundo y medio gastado en el saludo inicial. Con trescientos tests, eso serían dos minutos y medio de no hacer nada, y una suite tan lenta deja de ejecutarse.
La pregunta que responde este capítulo es: ¿cómo construyes algo una sola vez y lo compartes, sin que los tests que lo comparten tropiecen entre sí?
Al terminar este capítulo podrás
- Elegir el alcance de una fixture:
function,class,module,packageosession - Observar cómo se preparan y desmontan las fixtures con
--setup-show - Explicar por qué una fixture de alcance amplio que guarda un estado mutable hace que los tests dependan entre sí, y cómo solucionarlo
- Leer un error de
ScopeMismatchy saber qué fixture modificar - Compartir fixtures a través de
conftest.pyy sobrescribir una para un solo directorio - Explicar lo que cuesta
autouse=Truey listar todas las fixtures disponibles con--fixtures - Predecir el orden en que se crean las fixtures
Requisitos previos: fixtures.
Antes de escribir el test
El alcance (scope) no es algo que agregas al final para hacer rápida una suite lenta. Se decide antes de escribir el primer test, a partir de dos preguntas sobre cada parte de la preparación: ¿cuánto cuesta construirla y algún test la modifica? Responde a eso primero y el alcance adecuado vendrá solo.
El contrato. El código bajo prueba aquí es una pequeña tabla de usuarios. Lo que promete: una base de datos nueva no tiene usuarios; insertar un usuario añade exactamente una fila; el nombre que insertas es el nombre que recuperas. Cada una de esas promesas debe cumplirse sin importar qué tests se ejecutaron antes. Esa última cláusula es de lo que realmente trata este capítulo.
Qué debe estar preparado. Un entorno virtual con pytest instalado; sqlite3, que viene incluido con Python, por lo que no hay nada que instalar; el código bajo prueba importable desde los archivos de test (aquí, archivos juntos en un mismo directorio, ejecutados desde ese directorio); y una decisión sobre dónde residirán las fixtures compartidas: un conftest.py en la raíz del árbol de tests.
El plan de pruebas. Tres casos, cada uno de los cuales debe pasar por separado y en cualquier orden:
| caso | entrada | esperado | |---|---|---| | base de datos nueva | nada insertado | COUNT(*) es 0 | | una inserción | 'asha' | COUNT(*) es 1 | | ida y vuelta de nombre | 'ravi' | el primer nombre leído es 'ravi' |
El plan de preparación. Luego, el mismo tipo de tabla para aquello sobre lo que se apoyan los tests:
| recurso | coste de construcción | ¿un test lo modifica? | alcance | |---|---|---|---| | conexión + tabla | lento (medio segundo) | no | amplio: module o session | | las filas de la tabla | gratis | sí, cada inserción | function: se restablece tras cada test |
Lee la última columna con atención: un solo objeto, la conexión, contiene tanto algo lento como algo que cambia. Por eso el capítulo termina con dos fixtures (una amplia y una estrecha) en lugar de una.
Qué no probar. No pruebes sqlite3 en sí; está probado de manera mucho más exhaustiva de lo que tu código jamás lo estará. Tampoco pruebes las fixtures directamente: una fixture se comprueba mediante los tests que la usan y leyendo --setup-show. Y no pruebes el retraso de medio segundo: la velocidad es algo que mides con --durations, no algo sobre lo que haces un assert.
scope= — ¿una vez por cada qué?
El alcance (scope) de una fixture indica cuánto tiempo vive un valor antes de que pytest lo descarte y construya otro. El valor predeterminado es function: uno por cada test. Cambia una sola línea:
@pytest.fixture(scope="module")
def db():
conn = connect()
yield conn
conn.close()pytest -q:
..F [100%]
=================================== FAILURES ===================================
_____________________________ test_names_are_text ______________________________
db = <sqlite3.Connection object at 0x79350aa45c60>
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
> assert name == "ravi"
E AssertionError: assert 'asha' == 'ravi'
E
E - ravi
E + asha
test_users.py:35: AssertionError
=========================== short test summary info ============================
FAILED test_users.py::test_names_are_text - AssertionError: assert 'asha' == ...
1 failed, 2 passed in 0.52sSucedieron dos cosas. La ejecución pasó de 1.65 segundos a 0.52: una conexión en lugar de tres. Y un test que pasaba hace un momento ahora falla. Ten presente ese fallo; tendrá su propia sección más abajo. Primero, veamos los alcances en sí.
Hay cinco, del más estrecho al más amplio:
| alcance | un valor por | desmontado tras | |---|---|---| | function | test (el predeterminado) | cada test | | class | clase de test | el último test de la clase | | module | archivo de test | el último test del archivo | | package | directorio donde está definida la fixture | el último test de ese directorio, subdirectorios incluidos | | session | toda la ejecución de pytest | el último test de la ejecución |
Un contador hace visible la diferencia. Cada fixture a continuación cuenta cuántas veces fue construida:
test_scopes.py:
from collections import Counter
import pytest
made = Counter()
@pytest.fixture(scope="session")
def per_session():
made["session"] += 1
@pytest.fixture(scope="module")
def per_module():
made["module"] += 1
@pytest.fixture(scope="class")
def per_class():
made["class"] += 1
@pytest.fixture
def per_test():
made["function"] += 1
class TestFirst:
def test_a(self, per_session, per_module, per_class, per_test):
pass
def test_b(self, per_session, per_module, per_class, per_test):
pass
class TestSecond:
def test_c(self, per_session, per_module, per_class, per_test):
pass
def test_report():
print(dict(made))pytest -q -s (el -s deja pasar la salida de print):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sTres tests usaron las fixtures. La fixture de función se construyó tres veces, la fixture de clase dos veces (dos clases), y las fixtures de módulo y de sesión una sola vez cada una.
Verlo en acción: --setup-show
Contar funciona, pero pytest puede dibujar toda la cronología por ti. Ejecuta el mismo archivo con pytest -q --setup-show:
SETUP S per_session
SETUP M per_module
SETUP C per_class
SETUP F per_test
test_scopes.py::TestFirst::test_a (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
SETUP F per_test
test_scopes.py::TestFirst::test_b (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
SETUP C per_class
SETUP F per_test
test_scopes.py::TestSecond::test_c (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
test_scopes.py::test_report .
TEARDOWN M per_module
TEARDOWN S per_session
4 passed in 0.01sLa letra después de SETUP es el alcance (Session, Package, Module, Class, Function) y la sangría los anida. Léelo de arriba abajo y podrás ver exactamente cuándo nace cada valor y cuándo muere. Siempre que no estés seguro de lo que está haciendo una fixture, este es el primer flag que debes utilizar.
Una fixture compartida comparte su estado
Volvamos al fallo. Con scope="module", los tres tests recibieron la misma conexión. test_insert_one añadió a 'asha' y nada la eliminó. Luego test_names_are_text añadió a 'ravi', pidió el primer nombre y obtuvo 'asha'.
La señal más clara de este error es un test que pasa por sí solo. pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50sSolo pasa; acompañado falla. Los tests que dependen de lo que se ejecutó antes que ellos son peores que los tests lentos: fallan en un orden y pasan en otro, y te hacen perder una tarde entera cada vez.
La regla: amplía el alcance de lo costoso, mantén estrecho el alcance del estado. Divide la fixture en dos: una amplia que sea dueña de la conexión y una con alcance de función que limpie después de cada test:
@pytest.fixture(scope="module")
def connection():
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
yield connection
connection.rollback() # undo whatever this test wroteLos tests no cambian: siguen solicitando db. pytest -q --durations=3:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
(2 durations < 0.005s hidden. Use -vv to show these durations.)
3 passed in 0.51sUna conexión, tres tests que pasan. sqlite3 abre una transacción en el primer INSERT, y rollback() descarta toda la transacción, por lo que cada test comienza con una tabla vacía.
¿Por qué poner la limpieza en una fixture en lugar de comenzar cada test con DELETE FROM users? Porque una limpieza que cada test tiene que recordar es una limpieza que algún test olvidará, y el test que la olvida nunca es el que falla; el que falla es el siguiente. En la fixture, la limpieza ocurre después de cada test que solicita db, incluidos los tests escritos el próximo año por alguien que jamás ha leído este archivo. ¿Y por qué rollback() en lugar de DELETE? Un rollback deshace todo lo que el test escribió, en todas las tablas, sin que la fixture necesite saber cuáles fueron esas escrituras.
--setup-show muestra la división:
SETUP M connection
SETUP F db (fixtures used: connection)
test_users.py::test_starts_empty (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_insert_one (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_names_are_text (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN M connection
3 passed in 0.51sUna fixture con alcance de función puede solicitar libremente una de alcance más amplio, tal como db solicita connection aquí. La dirección contraria no está permitida.
ScopeMismatch — lo amplio no puede pedir lo estrecho
test_mismatch.py:
import sqlite3
import pytest
@pytest.fixture
def db_name():
return ":memory:"
@pytest.fixture(scope="session")
def connection(db_name):
conn = sqlite3.connect(db_name)
yield conn
conn.close()
def test_connects(connection):
assert connection.execute("SELECT 1").fetchone() == (1,)pytest -q:
E [100%]
==================================== ERRORS ====================================
_______________________ ERROR at setup of test_connects ________________________
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Requesting fixture stack:
test_mismatch.py:11: def connection(db_name)
Requested fixture:
test_mismatch.py:6: def db_name()
=========================== short test summary info ============================
ERROR test_mismatch.py::test_connects - Failed: ScopeMismatch: You tried to a...
1 error in 0.01sPiensa en lo que significaría esa solicitud. connection vive durante toda la ejecución; db_name se descarta después del primer test. El valor de sesión estaría reteniendo algo que, según sus propias reglas, ya no existe. Así que pytest se niega, y fíjate en que es un Error en la preparación (setup), no un Failure: el test ni siquiera llegó a ejecutarse.
El mensaje indica ambas fixtures y ambas líneas. La solución siempre es la misma: hacer que la fixture solicitada tenga un alcance al menos tan amplio como la que la solicita (aquí, @pytest.fixture(scope="session") en db_name), o hacer que la fixture solicitante tenga un alcance más estrecho.
conftest.py — fixtures sin imports
En cuanto un segundo archivo de test necesita db, copiar la fixture es la respuesta equivocada. Muévela a un archivo llamado exactamente conftest.py. Pytest carga ese archivo por sí mismo, y cada test en el mismo directorio y por debajo de él puede solicitar sus fixtures, sin ningún import.
Un proyecto con un subdirectorio:
conftest.py
test_users.py
reports/
conftest.py
test_reports.pyconftest.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, city TEXT)")
return conn
@pytest.fixture(scope="session")
def connection():
"""One database connection, shared by the whole run."""
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every write is rolled back after the test."""
yield connection
connection.rollback()test_users.py, sin ningún import en absoluto:
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1El alcance ahora es session: una sola conexión para todos los archivos del proyecto.
Sobrescribir una fixture para un solo directorio
Los tests en reports/ quieren una base de datos que ya contenga usuarios. Un conftest.py en ese directorio puede definir una fixture con el mismo nombre; para los tests dentro de reports/, la definición más cercana gana. Y si solicita su propio nombre, recibe la versión de la fixture padre:
reports/conftest.py:
import pytest
@pytest.fixture
def db(db):
"""The parent db, with three users already in it."""
db.executemany(
"INSERT INTO users VALUES (?, ?)",
[("asha", "Dhaka"), ("ravi", "Pune"), ("omar", "Cairo")],
)
return dbreports/test_reports.py:
def test_user_count(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 3
def test_cities_sorted(db):
rows = db.execute("SELECT city FROM users ORDER BY city").fetchall()
assert [city for (city,) in rows] == ["Cairo", "Dhaka", "Pune"]pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
reports/test_reports.py::test_user_count PASSED [ 25%]
reports/test_reports.py::test_cities_sorted PASSED [ 50%]
test_users.py::test_starts_empty PASSED [ 75%]
test_users.py::test_insert_one PASSED [100%]
============================== 4 passed in 0.51s ===============================Observa el orden. Los tests de reports/ se ejecutaron primero e insertaron tres usuarios cada vez; sin embargo, test_starts_empty, que se ejecutó después de ellos, siguió encontrando una tabla vacía. La db padre hizo rollback de las filas insertadas también, porque la sobrescritura está construida encima de ella. --setup-show muestra las dos capas, ambas llamadas db:
SETUP S connection
SETUP F db (fixtures used: connection)
SETUP F db (fixtures used: db)
reports/test_reports.py::test_user_count (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN F db(Ese es el primer test; los demás repiten el patrón). La sobrescritura solo tiene alcance hacia abajo: los tests en el directorio superior nunca la ven.
--fixtures — ¿qué puedo pedir aquí?
Con las fixtures repartidas entre varios archivos conftest.py, necesitas una forma de ver qué puede solicitar un test determinado. pytest --fixtures reports lista todo lo que es visible desde ese directorio. La lista comienza con las fixtures integradas de pytest (capítulo nueve) y las de los plugins instalados; las tuyas aparecen al final:
------------------------ fixtures defined from conftest ------------------------
connection [session scope] -- conftest.py:15
One database connection, shared by the whole run.
db -- conftest.py:23
The shared connection; every write is rolled back after the test.
db -- reports/conftest.py:5
The parent db, with three users already in it.Cada entrada proporciona el nombre, el alcance (cuando no es function), el archivo y la línea, y las primeras líneas del docstring. Esa última parte es el motivo para escribir un docstring de una sola línea en cada fixture compartida: es la documentación que la gente realmente va a leer.
autouse=True — y lo que cuesta
Una fixture marcada con autouse=True es utilizada por cada test que esté a su alcance, lo solicite el test o no. Añadida al conftest.py principal, esta comprueba que ningún test deje filas atrás:
@pytest.fixture(autouse=True)
def no_rows_left(connection):
"""After every test, check that the users table is empty again."""
yield
count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0, f"{count} row(s) left behind"Funciona: un test que escribe a través de connection directamente y se salta el rollback es interceptado en el desmontaje (teardown). Pero ahora añade un test que no tenga nada que ver con bases de datos:
test_text.py:
def slugify(title):
return title.strip().lower().replace(" ", "-")
def test_slugify():
assert slugify(" Hello World ") == "hello-world"pytest -q --setup-show test_text.py:
SETUP S connection
SETUP F no_rows_left (fixtures used: connection)
test_text.py::test_slugify (fixtures used: connection, no_rows_left) .
TEARDOWN F no_rows_left
TEARDOWN S connection
1 passed in 0.50sUn test de cadenas de una sola línea ahora abre una base de datos y tarda medio segundo. Ese es el coste de autouse: es invisible (nada en test_slugify indica que haya una base de datos involucrada) y es incondicional (se ejecuta para cada test por debajo de su conftest.py, incluidos los que no la necesitan). Reserva autouse para cosas que sean baratas y verdaderamente universales, y colócalo en el conftest.py más específico que cubra los tests que lo necesitan.
¿Qué fixture se crea primero?
Cuando un test necesita varias fixtures, pytest las ordena según tres reglas:
- El alcance más amplio primero. Session antes de module y antes de function, sin importar el orden en la firma del test.
- Dentro de un mismo alcance, las fixtures con
autouseprimero. - Las dependencias antes de las fixtures que las necesitan.
test_order.py:
import pytest
log = []
@pytest.fixture(scope="session")
def server():
log.append("server")
@pytest.fixture(scope="module")
def connection(server):
log.append("connection")
@pytest.fixture
def user():
log.append("user")
@pytest.fixture
def cart(user):
log.append("cart")
@pytest.fixture(autouse=True)
def audit():
log.append("audit")
def test_order(cart, connection):
print(log)pytest -q -s:
['server', 'connection', 'audit', 'user', 'cart']
.
1 passed in 0.01sEl test solicitó cart antes que connection, pero connection (con alcance de módulo) se construyó primero, después de su propia dependencia server. Entre las fixtures con alcance de función, la fixture con autouse audit se construyó primero, luego user, que cart necesita. El desmontaje se ejecuta exactamente en el orden inverso. Más allá de estas reglas, no confíes en el orden: si una fixture debe existir antes que otra, conviértela en dependencia nombrándola como parámetro.
Un ejemplo completo
Un pequeño módulo de tienda, probado contra una base de datos compartida en memoria.
shop.py:
def add_order(conn, customer, total):
conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)", (customer, total)
)
def revenue(conn):
return conn.execute("SELECT COALESCE(SUM(total), 0) FROM orders").fetchone()[0]
def top_customer(conn):
row = conn.execute(
"SELECT customer FROM orders GROUP BY customer "
"ORDER BY SUM(total) DESC LIMIT 1"
).fetchone()
return row[0] if row else Noneconftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the orders table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (customer TEXT, total REAL)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; whatever a test writes is rolled back."""
yield connection
connection.rollback()test_orders.py:
from shop import add_order, revenue, top_customer
def test_revenue_starts_at_zero(db):
assert revenue(db) == 0
def test_add_order_counts_toward_revenue(db):
add_order(db, "asha", 250.0)
assert revenue(db) == 250.0
def test_no_top_customer_without_orders(db):
assert top_customer(db) is Nonereports/conftest.py:
import pytest
from shop import add_order
@pytest.fixture
def db(db):
"""The parent db, with four orders from three customers already in it."""
add_order(db, "asha", 250.0)
add_order(db, "ravi", 900.0)
add_order(db, "omar", 120.0)
add_order(db, "asha", 700.0)
return dbreports/test_reports.py:
from shop import revenue, top_customer
def test_total_revenue(db):
assert revenue(db) == 1970.0
def test_top_customer_by_total(db):
assert top_customer(db) == "asha"pytest -v --durations=1:
============================= test session starts ==============================
collecting ... collected 5 items
reports/test_reports.py::test_total_revenue PASSED [ 20%]
reports/test_reports.py::test_top_customer_by_total PASSED [ 40%]
test_orders.py::test_revenue_starts_at_zero PASSED [ 60%]
test_orders.py::test_add_order_counts_toward_revenue PASSED [ 80%]
test_orders.py::test_no_top_customer_without_orders PASSED [100%]
============================= slowest 1 durations ==============================
0.50s setup reports/test_reports.py::test_total_revenue
============================== 5 passed in 0.51s ===============================Vale la pena fijarse en tres cosas.
Primero, la preparación lenta ocurrió una sola vez, en el primer test de la ejecución, y en ningún otro lugar. Ese es el beneficio del alcance session.
Segundo, los tests en test_orders.py se ejecutaron después de que los tests de informes hubieran insertado ocho pedidos en total, y aun así vieron cero ingresos. Sustituye connection.rollback() por pass y ejecuta de nuevo: el precio del estado compartido se hace evidente de inmediato:
FAILED test_orders.py::test_revenue_starts_at_zero - assert 3940.0 == 0
FAILED test_orders.py::test_add_order_counts_toward_revenue - assert 4190.0 =...
FAILED test_orders.py::test_no_top_customer_without_orders - AssertionError: ...
3 failed, 2 passed in 0.52sTercero, ningún archivo de test importa una fixture. test_orders.py importa shop, el código bajo prueba, y nada más; las fixtures llegan por su nombre desde el conftest.py más cercano.
Cuando algo falla
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Una fixture amplia solicitó una más estrecha. Amplía la fixture solicitada (aquí db_name) hasta al menos el alcance de la que la solicita, o reduce el de la solicitante. Las dos líneas bajo Requesting fixture stack te indican cuál es cuál.
AssertionError: assert 'asha' == 'ravi' — pero el test pasa cuando se ejecuta por sí solo El estado se está filtrando entre tests a través de una fixture de alcance más amplio que function. Ejecuta con --setup-show para ver qué fixture se comparte y luego divídela: mantén amplio el objeto costoso y añade una fixture con alcance de función que deshaga los cambios de cada test.
fixture 'report_title' not found La fixture existe, pero no donde este test pueda verla. Un conftest.py solo da servicio a su propio directorio y a los directorios que estén por debajo de él: una fixture en reports/conftest.py es invisible para un test en el directorio superior. Muévela hacia arriba a un conftest.py que cubra a ambos. pytest --fixtures path/to/test_file.py muestra exactamente lo que ese archivo puede ver.
Filas de un test aparecen en otro aunque db hace rollback Algo llamó a commit(). Un rollback solo descarta lo que no se ha confirmado (committed), por lo que el código que hace commit (o un test que usa connection directamente en lugar de db) escribe de forma permanente. Un control de seguridad como la fixture no_rows_left convierte eso en un ERROR at teardown ... AssertionError: 1 row(s) left behind visible.
Un test que no toca ninguna base de datos se ha vuelto lento Una fixture con autouse en algún nivel superior depende de la costosa. pytest --setup-show en ese test lista todas las fixtures que realmente usó, incluidas las que nunca solicitó.
from conftest import db en un archivo de test No importes desde conftest.py. Pytest ya te proporciona sus fixtures por nombre, e import conftest obtiene el conftest.py que Python encuentre primero por casualidad. En el proyecto anterior, añadir esa línea al test_users.py del nivel superior importó el que estaba en reports/ (la sobrescritura con datos iniciales) y test_starts_empty falló con assert 3 == 0. Una fixture importada también cuenta como definida en el propio archivo de test, lo cual prevalece sobre cualquier conftest.py. Solicita las fixtures nombrándolas como parámetros, y nada más.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Ejecuta con pytest -q -s. ¿Qué imprime test_report?
import pytest
calls = {"module": 0, "function": 0}
@pytest.fixture(scope="module")
def conn():
calls["module"] += 1
@pytest.fixture
def row(conn):
calls["function"] += 1
def test_a(row):
pass
def test_b(row):
pass
def test_c(conn):
pass
def test_report():
print(calls)- A{'module': 1, 'function': 2}
- B{'module': 3, 'function': 2}
- C{'module': 1, 'function': 3}
- D{'module': 3, 'function': 3}
Este test da error con ScopeMismatch antes de ejecutarse. ¿Cuál es la solución correcta más pequeña?
import pytest
@pytest.fixture
def settings():
return {"db": ":memory:"}
@pytest.fixture(scope="session")
def connection(settings):
return settings["db"]
def test_connects(connection):
assert connection == ":memory:"- APoner `test_connects` dentro de una clase
- BAñadir `autouse=True` a `connection`
- CMover `settings` debajo de `connection` en el archivo
- DDarle a `settings` `@pytest.fixture(scope="session")`
Ambos archivos conftest.py definen db. ¿Qué db recibe test_count en reports/test_reports.py?
# conftest.py
@pytest.fixture
def db(connection):
yield connection
connection.rollback()
# reports/conftest.py
@pytest.fixture
def db(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
return db
# reports/test_reports.py
def test_count(db):
...- ALa del nivel superior, porque se carga primero
- BLa de `reports/`, pero su parámetro `db` se recibe a sí mismo, por lo que entra en recursión infinita
- CLa de `reports/`, que recibe la del nivel superior y añade una fila encima de ella
- DNinguna: dos fixtures con el mismo nombre es un error
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
Construye un pequeño proyecto en torno a un library.py con dos funciones: add_book(conn, title, author) y books_by(conn, author), la cual devuelve una lista ordenada de títulos.
- En un
conftest.pyen el nivel superior, escribe una fixtureconnectioncon alcance de sesión que abrasqlite3.connect(":memory:"), cree una tablabooks (title TEXT, author TEXT)y haga una pausa de medio segundo para simular un servidor lento. Ponle un docstring. - Añade sobre ella una fixture
dbcon alcance de función que haga rollback después de cada test. - Escribe
test_library.pycon tres tests que añadan libros y compruebenbooks_by. Ejecutapytest -q --durations=3y confirma que el medio segundo aparece una sola vez. - Crea
search/conftest.pyque sobrescribadbpara comenzar con cinco libros ya cargados, ysearch/test_search.pycon dos tests que dependan de ellos. - Ejecuta
pytest --setup-showy localiza las dos capas dedben la salida para los tests desearch/. Luego ejecutapytest --fixtures searchy comprueba que aparecen ambos docstrings.
Luego rómpelo a propósito, dos veces. Primero, sustituye la línea rollback() por pass y ejecuta toda la suite: ¿qué tests fallan y pasa cada uno cuando se ejecuta por separado? Segundo, dale a connection una fixture db_name con alcance de función de la que depender, y lee el mensaje de ScopeMismatch línea por línea.
Esos cinco minutos son el capítulo. El alcance es una decisión de velocidad, pero los errores que genera son problemas de aislamiento, y la única forma de aprender a detectarlos es provocar unos cuantos tú mismo.
Solución
La estructura:
library.py
conftest.py
test_library.py
search/
conftest.py
test_search.pylibrary.py:
def add_book(conn, title, author):
conn.execute("INSERT INTO books VALUES (?, ?)", (title, author))
def books_by(conn, author):
rows = conn.execute(
"SELECT title FROM books WHERE author = ? ORDER BY title", (author,)
).fetchall()
return [title for (title,) in rows]conftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the books table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE books (title TEXT, author TEXT)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every test's writes are rolled back."""
yield connection
connection.rollback()test_library.py:
from library import add_book, books_by
def test_unknown_author_has_no_books(db):
assert books_by(db, "Nobody") == []
def test_one_book(db):
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali"]
def test_titles_come_back_sorted(db):
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali", "Kabuliwala"]search/conftest.py:
import pytest
from library import add_book
@pytest.fixture
def db(db):
"""The parent db, with five books by three authors already in it."""
add_book(db, "Gitanjali", "Tagore")
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Godaan", "Premchand")
add_book(db, "Nirmala", "Premchand")
add_book(db, "Palace Walk", "Mahfouz")
return dbsearch/test_search.py:
from library import books_by
def test_two_books_by_premchand(db):
assert books_by(db, "Premchand") == ["Godaan", "Nirmala"]
def test_author_match_is_exact(db):
assert books_by(db, "tagore") == []pytest -q --durations=3:
..... [100%]
============================= slowest 3 durations ==============================
0.50s setup search/test_search.py::test_two_books_by_premchand
(2 durations < 0.005s hidden. Use -vv to show these durations.)
5 passed in 0.51spytest -q --setup-show search muestra, para cada uno de los dos tests, la misma imagen que en el capítulo: SETUP F db (fixtures used: connection) seguido de SETUP F db (fixtures used: db): la capa padre y luego la sobrescritura encima de ella. pytest --fixtures search enumera connection con [session scope], ambas fixtures db con su archivo y línea, y los tres docstrings.
El primer experimento (sustituir rollback() por pass) produce:
FAILED test_library.py::test_one_book - AssertionError: assert ['Gitanjali',....
FAILED test_library.py::test_titles_come_back_sorted - AssertionError: assert...
2 failed, 3 passed in 0.52sy pytest -q test_library.py::test_one_book por sí solo:
. [100%]
1 passed in 0.50sLos tests de search/ se ejecutaron primero y dejaron cinco libros atrás, por lo que test_one_book encontró dos títulos de Tagore en lugar de uno. Por sí solo, pasa. Esa es la firma del estado filtrado, exactamente como en el capítulo. El segundo experimento convierte los cinco tests en E, cada uno con el mismo ScopeMismatch que viste antes, apuntando ahora a conftest.py: def connection(db_name) es el solicitante, def db_name() la fixture que es demasiado estrecha.
El porqué de cada decisión:
connectiontiene alcance de sesión porque es lo único lento, y nada de lo que hace un test cambia la conexión en sí (solo las filas que contiene).dbtiene alcance de función porque las filas sí cambian. Su único trabajo es el rollback después de cada test; los tests nunca necesitan saber que existe más allá de su nombre.- Los tests solicitan
db, nuncaconnection. Un test que tomaraconnectiondirectamente se saltaría el rollback y filtraría sus filas al siguiente test. - La sobrescritura en
search/solicitadb, noconnection, de modo que los libros iniciales pasan a formar parte del propio trabajo del test y el rollback de la fixture padre los elimina junto con todo lo demás. Si la limpieza endbcambia alguna vez, la sobrescritura hereda el cambio sin tener que tocarla. test_author_match_is_exactestá en el plan a propósito: fija que"tagore"no es"Tagore", un límite que alguien algún día querrá cambiar, y que debería cambiar a propósito con un test que falle para advertírselo.- Cada fixture compartida tiene un docstring, porque
pytest --fixtureses la forma en que la siguiente persona descubrirá qué puede solicitar.
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