Portée des fixtures et conftest.py — partager la préparation en toute sécurité
Construisez une préparation lente une seule fois et partagez-la : les cinq portées, --setup-show, le danger de l'état partagé et ScopeMismatch, conftest.py pour des fixtures sans import et des surcharges par dossier, ce que coûte autouse et l'ordre de création des fixtures.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Les fixtures du chapitre précédent étaient recréées à neuf pour chaque test. C'est le comportement par défaut idéal — jusqu'à ce que l'élément à construire devienne coûteux. Voici une connexion à une base de données qui prend une demi-seconde à s'ouvrir, à l'image d'une négociation (handshake) avec un vrai serveur :
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 indique où le temps a été passé :
... [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.65sTrois tests, trois connexions, une seconde et demie perdue en négociations. Avec trois cents tests, cela représente deux minutes et demie d'attente inutile, et une suite de tests aussi lente finit par ne plus être exécutée.
La question à laquelle répond ce chapitre est la suivante : comment construire un élément une seule fois et le partager — sans que les tests qui le partagent ne se gênent mutuellement ?
À la fin de ce chapitre, vous saurez
- Choisir la portée d'une fixture :
function,class,module,packageousession - Observer la préparation et le nettoyage des fixtures avec
--setup-show - Expliquer pourquoi une fixture à portée large contenant un état mutable rend les tests dépendants les uns des autres — et corriger ce problème
- Lire une erreur
ScopeMismatchet savoir quelle fixture modifier - Partager des fixtures via
conftest.py, et en surcharger une pour un seul dossier - Expliquer ce que coûte
autouse=True, et lister toutes les fixtures disponibles avec--fixtures - Prédire l'ordre dans lequel les fixtures sont créées
Prérequis : fixtures.
Avant d'écrire le test
La portée n'est pas quelque chose que l'on ajoute à la fin pour accélérer une suite de tests trop lente. Elle se décide avant d'écrire le premier test, à partir de deux questions posées pour chaque élément de préparation : combien coûte-t-il à construire, et un test le modifie-t-il ? Répondez d'abord à ces questions, et la bonne portée en découlera naturellement.
Le contrat. Le code testé ici est une petite table d'utilisateurs. Ce qu'il promet : une nouvelle base de données ne contient aucun utilisateur ; insérer un utilisateur ajoute exactement une ligne ; le nom inséré est celui que l'on récupère en lecture. Chacune de ces promesses doit être tenue, quels que soient les tests exécutés auparavant. C'est précisément sur cette dernière condition que porte ce chapitre.
Ce qui doit être en place. Un environnement virtuel avec pytest installé ; sqlite3, fourni avec Python, il n'y a donc rien à installer ; le code testé importable depuis les fichiers de test (ici, des fichiers côte à côte dans un même dossier, exécutés depuis ce dossier) ; et une décision sur l'endroit où résideront les fixtures partagées — un fichier conftest.py à la racine de l'arborescence des tests.
Le plan de test. Trois cas, dont chacun doit réussir isolément et dans n'importe quel ordre :
| Cas | Entrée | Attendu | |---|---|---| | Base neuve | Rien d'inséré | COUNT(*) vaut 0 | | Une insertion | 'asha' | COUNT(*) vaut 1 | | Aller-retour du nom | 'ravi' | Le premier nom lu est 'ravi' |
Le plan de préparation. Ensuite, le même type de tableau pour ce sur quoi reposent les tests :
| Ressource | Coût de construction | Un test la modifie-t-il ? | Portée | |---|---|---|---| | Connexion + table | Lent (une demi-seconde) | Non | Large — module ou session | | Les lignes de la table | Gratuit | Oui, à chaque insertion | function — réinitialisée après chaque test |
Lisez attentivement la dernière colonne : un seul objet, la connexion, contient à la fois un élément lent et un élément changeant. C'est pourquoi ce chapitre aboutit à deux fixtures — une large, une étroite — au lieu d'une seule.
Ce qu'il ne faut pas tester. Pas sqlite3 lui-même ; il est testé bien plus rigoureusement que votre propre code ne le sera jamais. Pas les fixtures directement — une fixture est vérifiée par les tests qui l'utilisent, et en observant --setup-show. Et pas le délai d'une demi-seconde : la vitesse se mesure avec --durations, elle ne se vérifie pas avec un assert.
scope= — une fois par quoi ?
La portée (scope) d'une fixture indique combien de temps une valeur reste active avant que pytest ne la détruise et n'en construise une autre. La valeur par défaut est function : une par test. Modifiez une seule ligne :
@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.52sDeux choses se sont produites. L'exécution est passée de 1,65 seconde à 0,52 — une seule connexion au lieu de trois. Et un test qui réussissait il y a un instant échoue désormais. Gardez cet échec en mémoire ; une section lui est consacrée ci-dessous. Examinons d'abord les portées elles-mêmes.
Il en existe cinq, de la plus étroite à la plus large :
| Portée | Une valeur par | Nettoyée après | |---|---|---| | function | Test (par défaut) | Chaque test | | class | Classe de test | Le dernier test de la classe | | module | Fichier de test | Le dernier test du fichier | | package | Dossier où la fixture est définie | Le dernier test de ce dossier, sous-dossiers inclus | | session | Session pytest entière | Le dernier test de la session |
Un compteur permet de rendre la différence bien visible. Chaque fixture ci-dessous compte le nombre de fois où elle a été construite :
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 (l'option -s laisse passer les affichages de print) :
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sTrois tests ont utilisé les fixtures. La fixture de fonction a été construite trois fois, la fixture de classe deux fois (deux classes), et les fixtures de module et de session une fois chacune.
Observer le déroulement : --setup-show
Compter fonctionne, mais pytest peut afficher la chronologie complète à votre place. Exécutez le même fichier avec 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 lettre après SETUP indique la portée — Session, Package, Module, Class, Function — et l'indentation illustre leur imbrication. En lisant de haut en bas, vous pouvez voir exactement quand chaque valeur est créée et quand elle disparaît. Chaque fois que vous avez un doute sur ce que fait une fixture, c'est la première option à utiliser.
Une fixture partagée partage son état
Revenons à l'échec. Avec scope="module", les trois tests ont reçu la même connexion. test_insert_one a ajouté 'asha' et rien ne l'a supprimée. Ensuite, test_names_are_text a ajouté 'ravi', a demandé le premier nom et a obtenu 'asha'.
Le signe le plus flagrant de ce bug est un test qui réussit lorsqu'il tourne tout seul. pytest -q test_users.py::test_names_are_text :
. [100%]
1 passed in 0.50sIsolé, il réussit ; en groupe, il échoue. Les tests qui dépendent de ce qui a tourné avant eux sont pires que les tests lents : ils échouent dans un ordre et réussissent dans un autre, vous faisant perdre une après-midi à chaque fois.
La règle : élargissez la portée de l'élément coûteux, gardez étroite la portée de l'état. Divisez la fixture en deux — une fixture large qui possède la connexion, et une fixture de portée fonction qui nettoie après chaque 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 wroteLes tests ne changent pas — ils demandent toujours 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.51sUne seule connexion, trois tests réussis. sqlite3 ouvre une transaction lors du premier INSERT, et rollback() annule l'intégralité de la transaction, de sorte que chaque test démarre avec une table vide.
Pourquoi placer le nettoyage dans une fixture plutôt que de commencer chaque test par DELETE FROM users ? Parce qu'un nettoyage dont chaque test doit se souvenir est un nettoyage qu'un test finira par oublier — et le test qui oublie n'est jamais celui qui échoue ; c'est le suivant. Dans la fixture, le nettoyage s'exécute après chaque test qui demande db, y compris les tests écrits l'année prochaine par quelqu'un qui n'a jamais lu ce fichier. Et pourquoi rollback() plutôt que DELETE ? Un rollback annule tout ce que le test a écrit, dans toutes les tables, sans que la fixture n'ait besoin de savoir quelles étaient ces écritures.
--setup-show montre cette séparation :
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.51sUne fixture de portée fonction peut librement demander une fixture plus large, comme db demande connection ici. L'inverse n'est pas autorisé.
ScopeMismatch — une portée large ne peut pas demander une portée étroite
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.01sRéfléchissez à ce qu'impliquerait une telle demande. connection vit pendant toute la session ; db_name est détruite après le premier test. La valeur de portée session conserverait une référence vers quelque chose qui, selon ses propres règles, n'existe plus. pytest refuse donc — et remarquez qu'il s'agit d'une Erreur lors de la préparation, et non d'un échec (Failure) : le test n'a jamais été exécuté.
Le message nomme les deux fixtures et les deux lignes. La correction est toujours la même : donner à la fixture demandée une portée au moins aussi large que celle de la fixture qui la demande — ici @pytest.fixture(scope="session") sur db_name — ou bien rétrécir la portée de la fixture demandeuse.
conftest.py — des fixtures sans import
Dès qu'un deuxième fichier de test a besoin de db, copier la fixture est une mauvaise solution. Déplacez-la dans un fichier nommé exactement conftest.py. Pytest charge ce fichier automatiquement, et chaque test situé dans le même dossier ou dans ses sous-dossiers peut demander ses fixtures — sans aucun import.
Un projet comportant un sous-dossier :
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 — sans le moindre import :
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 == 1La portée est désormais session : une seule connexion pour chaque fichier du projet.
Surcharger une fixture pour un dossier
Les tests situés dans reports/ veulent tous une base de données contenant déjà des utilisateurs. Un fichier conftest.py placé dans ce dossier peut définir une fixture portant le même nom ; pour les tests de reports/, la définition la plus proche l'emporte. Et si elle demande son propre nom en paramètre, elle reçoit la version du parent :
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 ===============================Observez l'ordre. Les tests de reports/ se sont exécutés en premier et ont inséré trois utilisateurs à chaque fois — pourtant, test_starts_empty, qui a tourné après eux, a quand même trouvé une table vide. La fixture parente db a également annulé les lignes pré-insérées, car la surcharge est construite par-dessus elle. --setup-show montre ces deux couches, toutes deux nommées 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(Il s'agit du premier test ; les autres reproduisent le même schéma.) La surcharge ne s'applique que vers le bas : les tests du dossier racine ne la voient jamais.
--fixtures — que puis-je demander ici ?
Avec des fixtures réparties dans plusieurs fichiers conftest.py, vous avez besoin d'un moyen de voir ce qu'un test donné peut demander. pytest --fixtures reports liste tout ce qui est visible depuis ce dossier. La liste commence par les fixtures intégrées de pytest (chapitre neuf) et celles des plugins installés ; les vôtres apparaissent à la fin :
------------------------ 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.Chaque entrée indique le nom, la portée (lorsqu'elle n'est pas function), le fichier et la ligne, ainsi que les premières lignes de la docstring. C'est précisément pour cela qu'il convient d'écrire une docstring d'une ligne sur chaque fixture partagée : c'est la documentation que les développeurs liront réellement.
autouse=True — et ce que cela coûte
Une fixture marquée autouse=True est utilisée par chaque test situé à sa portée, que le test la demande ou non. Ajoutée au conftest.py racine, celle-ci vérifie qu'aucun test ne laisse de lignes derrière lui :
@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"Cela fonctionne — un test qui écrit directement via connection et ignore le rollback est intercepté lors du nettoyage (teardown). Mais ajoutons maintenant un test qui n'a rien à voir avec les bases de données :
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 d'une seule ligne sur une chaîne de caractères ouvre désormais une base de données et prend une demi-seconde. C'est le prix à payer pour autouse : elle est invisible — rien dans test_slugify n'indique qu'une base de données est impliquée — et elle est inconditionnelle — elle s'exécute pour chaque test situé sous son conftest.py, y compris ceux qui n'en ont aucunement besoin. Réservez autouse aux opérations peu coûteuses et véritablement universelles, et placez-la dans le conftest.py le plus ciblé possible qui couvre uniquement les tests concernés.
Quelle fixture est créée en premier ?
Lorsqu'un test a besoin de plusieurs fixtures, pytest les ordonne selon trois règles :
- La portée la plus large d'abord. Session avant module avant fonction, quel que soit l'ordre dans la signature du test.
- Au sein d'une même portée, les fixtures
autoused'abord. - Les dépendances avant les fixtures qui en ont besoin.
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.01sLe test a demandé cart avant connection, mais connection — de portée module — a été construite en premier, après sa propre dépendance server. Parmi les fixtures de portée fonction, la fixture autouse audit est arrivée en tête, puis user, dont cart a besoin. Le nettoyage s'exécute exactement dans l'ordre inverse. En dehors de ces règles, ne vous fiez pas à l'ordre : si une fixture doit exister avant une autre, faites-en une dépendance en la nommant comme paramètre.
Un exemple complet
Un petit module de boutique, testé avec une unique base de données partagée en mémoire.
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 ===============================Trois éléments méritent d'être soulignés.
Premièrement, la préparation lente a eu lieu une seule fois, lors du premier test de la session, et nulle part ailleurs. C'est là toute la rentabilité de la portée session.
Deuxièmement, les tests de test_orders.py se sont exécutés après que les tests de rapport avaient inséré huit commandes au total, et ils ont pourtant constaté des revenus à zéro. Remplacez connection.rollback() par pass et lancez à nouveau : le coût d'un état partagé saute immédiatement aux yeux :
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.52sTroisièmement, aucun fichier de test n'importe de fixture. test_orders.py importe shop, le code testé, et rien d'autre ; les fixtures lui parviennent par leur nom depuis le conftest.py le plus proche.
En cas de problème
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Une fixture large a demandé une fixture plus étroite. Élargissez la fixture demandée (db_name ici) pour qu'elle ait au moins la portée de celle qui la demande, ou rétrécissez la portée de cette dernière. Les deux lignes sous Requesting fixture stack vous indiquent précisément qui est qui.
AssertionError: assert 'asha' == 'ravi' — pourtant le test réussit lorsqu'il est exécuté seul De l'état s'échappe d'un test à l'autre par l'intermédiaire d'une fixture de portée supérieure à function. Lancez avec --setup-show pour voir quelle fixture est partagée, puis séparez-la : gardez l'objet coûteux dans une portée large et ajoutez une fixture de portée fonction qui annule les modifications de chaque test.
fixture 'report_title' not found La fixture existe, mais pas à un endroit où ce test peut la voir. Un fichier conftest.py ne dessert que son propre dossier et les dossiers situés sous lui — une fixture dans reports/conftest.py est invisible pour un test situé dans le dossier parent. Remontez-la dans un conftest.py qui chapeaute les deux. pytest --fixtures path/to/test_file.py vous montre exactement ce que ce fichier peut voir.
Des lignes d'un test apparaissent dans un autre bien que db effectue un rollback Quelque chose a appelé commit(). Un rollback ne rejette que ce qui n'a pas été validé par un commit ; par conséquent, un code qui valide — ou un test qui utilise directement connection au lieu de db — écrit de façon permanente. Un garde-fou comme la fixture no_rows_left transforme cela en une alerte visible : ERROR at teardown ... AssertionError: 1 row(s) left behind.
Un test qui ne touche à aucune base de données est devenu lent Une fixture autouse située quelque part au-dessus de lui dépend de la fixture coûteuse. pytest --setup-show sur ce test listera toutes les fixtures qu'il a réellement utilisées, y compris celles qu'il n'a jamais demandées.
from conftest import db dans un fichier de test N'importez rien depuis un fichier conftest.py. Pytest vous fournit déjà ses fixtures par leur nom, et import conftest importera le premier conftest.py que Python trouvera. Dans le projet ci-dessus, ajouter cette ligne dans le test_users.py de premier niveau a importé celui de reports/ — la surcharge avec les données initiales — et test_starts_empty a échoué avec assert 3 == 0. De plus, une fixture importée est considérée comme définie dans le fichier de test lui-même, ce qui prend le pas sur n'importe quel conftest.py. Demandez les fixtures en les nommant comme paramètres, et rien d'autre.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Exécutez avec pytest -q -s. Qu'affiche 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}
Ce test produit une erreur ScopeMismatch avant son exécution. Quelle est la correction minimale appropriée ?
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:"- APlacer `test_connects` dans une classe
- BAjouter `autouse=True` à `connection`
- CDéplacer `settings` sous `connection` dans le fichier
- DAttribuer à `settings` le décorateur `@pytest.fixture(scope="session")`
Les deux fichiers conftest.py définissent db. Quelle db reçoit test_count dans 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):
...- ACelle du niveau racine, car elle est chargée en premier
- BCelle de `reports/`, mais son paramètre `db` se reçoit lui-même, provoquant une récursion infinie
- CCelle de `reports/`, qui reçoit celle du niveau racine et lui ajoute une ligne par-dessus
- DAucune des deux — deux fixtures avec le même nom constituent une erreur
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Construisez un petit projet autour d'un module library.py comportant deux fonctions : add_book(conn, title, author) et books_by(conn, author), qui renvoie une liste triée de titres.
- Dans un fichier
conftest.pyau niveau racine, écrivez une fixtureconnectionde portée session qui ouvresqlite3.connect(":memory:"), crée une tablebooks (title TEXT, author TEXT)et marque une pause d'une demi-seconde pour simuler un serveur lent. Donnez-lui une docstring. - Ajoutez par-dessus une fixture
dbde portée fonction qui effectue un rollback après chaque test. - Écrivez
test_library.pyavec trois tests qui ajoutent chacun des livres et vérifientbooks_by. Lancezpytest -q --durations=3et confirmez que la demi-seconde n'apparaît qu'une seule fois. - Créez
search/conftest.pyqui surchargedbpour démarrer avec cinq livres déjà présents dans la base, etsearch/test_search.pyavec deux tests reposant sur ces données. - Lancez
pytest --setup-showet repérez les deux couches dedbdans la sortie des tests desearch/. Lancez ensuitepytest --fixtures searchet vérifiez que les deux docstrings apparaissent bien.
Ensuite, cassez le code volontairement, deux fois. D'abord, remplacez la ligne rollback() par pass et lancez toute la suite — quels tests échouent, et chacun réussit-il lorsqu'il est exécuté seul ? Ensuite, faites dépendre connection d'une fixture db_name de portée fonction, et lisez le message ScopeMismatch ligne par ligne.
Ces cinq minutes résument tout le chapitre. La portée est un choix de performance, mais les bugs qu'elle provoque sont des bugs d'isolation — et la seule façon d'apprendre à les repérer est d'en provoquer quelques-uns vous-même.
Solution
L'organisation des fichiers :
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 affiche, pour chacun des deux tests, la même configuration que dans le chapitre : SETUP F db (fixtures used: connection) suivi de SETUP F db (fixtures used: db) — la couche parente, puis la surcharge par-dessus. pytest --fixtures search liste connection avec [session scope], les deux fixtures db avec leur fichier et leur ligne, et les trois docstrings.
La première expérience — rollback() remplacé par pass — donne :
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.52set pytest -q test_library.py::test_one_book exécuté isolément :
. [100%]
1 passed in 0.50sLes tests de search/ ont tourné en premier et ont laissé cinq livres derrière eux, de sorte que test_one_book a trouvé deux ouvrages de Tagore au lieu d'un seul. Seul, il réussit. C'est la signature typique d'une fuite d'état, exactement comme dans le chapitre. La seconde expérience transforme les cinq tests en erreurs E, chacun avec le même message ScopeMismatch rencontré plus tôt, pointant désormais vers conftest.py : def connection(db_name) est le demandeur, def db_name() la fixture dont la portée est trop étroite.
Les raisons derrière chaque décision :
connectiona une portée session car c'est le seul élément lent, et rien de ce que fait un test ne modifie la connexion elle-même — seulement les lignes qu'elle contient.dba une portée fonction car les lignes, elles, sont modifiées. Son unique rôle est d'effectuer le rollback après chaque test ; les tests n'ont jamais besoin de connaître son existence au-delà de son nom.- Les tests demandent
db, jamaisconnection. Un test qui prendrait directementconnectioncontournerait le rollback et propagerait ses lignes dans le test suivant. - La surcharge dans
search/demandedb, et nonconnection, afin que les livres insérés fassent partie du travail du test lui-même et que le rollback du parent les supprime avec tout le reste. Si le nettoyage dansdbest modifié un jour, la surcharge hérite du changement sans avoir à être retouchée. test_author_match_is_exactfigure délibérément dans le plan : il garantit que"tagore"n'est pas"Tagore", une frontière que quelqu'un voudra peut-être modifier un jour — et devra modifier intentionnellement, guidé par un test en échec.- Chaque fixture partagée possède une docstring, car
pytest --fixturesest le moyen par lequel la personne suivante découvrira ce qu'elle peut utiliser.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz