نطاق الـ fixture وملف conftest.py — مشاركة التهيئة بأمان
ابنِ التهيئة البطيئة مرة واحدة وشاركها: النطاقات الخمسة، و--setup-show، وخطر الحالة المشتركة و ScopeMismatch، وconftest.py لـ fixtures بلا استيراد وتجاوزها لكل مجلد، وكلفة autouse، وترتيب إنشاء الـ fixtures.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
كانت الـ fixtures في الفصل السابق تُبنى من جديد لكل اختبار. وهذا هو الافتراض الصحيح — إلى أن يصبح الشيء الذي يُبنى مكلفاً. إليك اتصالاً بقاعدة بيانات يستغرق فتحه نصف ثانية، كما قد تستغرق مصافحة حقيقية مع خادم:
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 أين ذهب الوقت:
... [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.65sثلاثة اختبارات، ثلاثة اتصالات، ثانية ونصف أُنفقت في المصافحة وحدها. مع ثلاثمئة اختبار تصبح دقيقتين ونصفاً من لا شيء، ومجموعة اختبارات بهذا البطء يتوقف الناس عن تشغيلها.
السؤال الذي يجيب عنه هذا الفصل هو: كيف تبني شيئاً مرة واحدة وتتشاركه — دون أن تتعثر الاختبارات التي تتشاركه ببعضها؟
بنهاية هذا الفصل ستكون قادراً على
- اختيار نطاق (scope) الـ fixture:
functionأوclassأوmoduleأوpackageأوsession - مشاهدة الـ fixtures وهي تُهيَّأ وتُفكَّك باستخدام
--setup-show - شرح لماذا تجعل fixture واسعة النطاق تحمل حالة قابلة للتغيير الاختباراتِ معتمدة على بعضها — وإصلاح ذلك
- قراءة خطأ
ScopeMismatchومعرفة أي fixture يجب تغييرها - مشاركة الـ fixtures عبر
conftest.py، وتجاوز (override) إحداها لمجلد واحد - بيان كلفة
autouse=True، وعرض كل الـ fixtures المتاحة باستخدام--fixtures - التنبؤ بالترتيب الذي تُنشأ به الـ fixtures
المتطلبات المسبقة: الـ fixtures.
قبل أن تكتب الاختبار
النطاق ليس شيئاً تضيفه في النهاية لتسريع مجموعة اختبارات بطيئة. إنه يُقرَّر قبل كتابة أول اختبار، انطلاقاً من سؤالين عن كل جزء من التهيئة: كم يكلّف بناؤه، وهل يغيّره أي اختبار؟ أجب عنهما أولاً، وسيتبعهما النطاق الصحيح.
العقد. الكود الذي نختبره هنا جدول users صغير. ما يَعِد به: قاعدة البيانات الجديدة لا مستخدمين فيها؛ إدراج مستخدم يضيف صفاً واحداً بالضبط؛ الاسم الذي تُدرجه هو الاسم الذي تستعيده. ويجب أن يصمد كل وعد من هذه الوعود أياً كانت الاختبارات التي جرت قبله. وهذا الشرط الأخير هو موضوع هذا الفصل في الحقيقة.
ما يجب أن يكون جاهزاً. بيئة افتراضية مثبّت فيها pytest؛ وsqlite3 الذي يأتي مع بايثون، فلا شيء يحتاج إلى تثبيت؛ وإمكانية استيراد الكود المُختبَر من ملفات الاختبار (هنا، الملفات متجاورة في مجلد واحد وتُشغَّل منه)؛ وقرار بشأن مكان الـ fixtures المشتركة — ملف conftest.py في أعلى شجرة الاختبارات.
خطة الاختبار. ثلاث حالات، يجب أن تنجح كل منها منفردة و بأي ترتيب:
| الحالة | المدخل | المتوقع | |---|---|---| | قاعدة بيانات جديدة | لم يُدرج شيء | COUNT(*) يساوي 0 | | إدراج واحد | 'asha' | COUNT(*) يساوي 1 | | عودة الاسم كما هو | 'ravi' | أول اسم يُقرأ هو 'ravi' |
خطة التهيئة. ثم جدول من النوع نفسه لما تقف عليه الاختبارات:
| المورد | كلفة البناء | هل يغيّره اختبار؟ | النطاق | |---|---|---|---| | الاتصال + الجدول | بطيء (نصف ثانية) | لا | واسع — module أو session | | الصفوف في الجدول | مجاني | نعم، مع كل إدراج | function — يُعاد ضبطه بعد كل اختبار |
اقرأ العمود الأخير بعناية: كائن واحد، هو الاتصال، يحمل شيئاً بطيئاً وشيئاً متغيراً معاً. ولهذا ينتهي الفصل إلى اثنتين من الـ fixtures بدلاً من واحدة — واحدة واسعة وأخرى ضيقة.
ما لا يجب اختباره. لا تختبر sqlite3 نفسه؛ فهو مُختبَر بعمق يفوق ما سيُختبر به كودك يوماً. ولا تختبر الـ fixtures مباشرة — فالـ fixture تُفحص عبر الاختبارات التي تستخدمها، وعبر قراءة --setup-show. ولا تختبر تأخير نصف الثانية: السرعة شيء تقيسه بـ --durations، لا شيء تتحقق منه بـ assert.
scope= — مرة واحدة لكل ماذا؟
نطاق الـ fixture يحدد كم تعيش القيمة الواحدة قبل أن يتخلص منها pytest ويبني غيرها. الافتراضي هو function: واحدة لكل اختبار. غيّر سطراً واحداً:
@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.52sحدث أمران. انخفض زمن التشغيل من 1.65 ثانية إلى 0.52 — اتصال واحد بدلاً من ثلاثة. واختبار كان ينجح قبل لحظة صار يفشل الآن. احتفظ بهذا الفشل في ذهنك؛ فله قسم خاص أدناه. لكن أولاً، النطاقات نفسها.
هناك خمسة، من الأضيق إلى الأوسع:
| النطاق | قيمة واحدة لكل | تُفكَّك بعد | |---|---|---| | function | اختبار (الافتراضي) | كل اختبار | | class | صنف اختبارات | آخر اختبار في الصنف | | module | ملف اختبارات | آخر اختبار في الملف | | package | المجلد الذي عُرّفت فيه الـ fixture | آخر اختبار في ذلك المجلد، بما فيه المجلدات الفرعية | | session | تشغيل pytest بأكمله | آخر اختبار في التشغيل |
العدّاد يجعل الفرق مرئياً. كل fixture أدناه تعدّ كم مرة بُنيت:
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 (الخيار -s يسمح لـ print بالظهور):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sثلاثة اختبارات استخدمت الـ fixtures. بُنيت fixture الـ function ثلاث مرات، وfixture الـ class مرتين (صنفان)، وfixture الـ module والـ session مرة واحدة لكل منهما.
مشاهدة ما يحدث: --setup-show
العدّ يفي بالغرض، لكن pytest يستطيع أن يرسم لك الخط الزمني كاملاً. شغّل الملف نفسه بـ 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.01sالحرف بعد SETUP هو النطاق — Session وPackage وModule وClass وFunction — والإزاحة تُداخِل بعضها في بعض. اقرأ من الأعلى إلى الأسفل وسترى بالضبط متى وُلدت كل قيمة ومتى انتهت. كلما شككت فيما تفعله fixture ما، فهذا أول خيار تلجأ إليه.
الـ fixture المشتركة تشارك حالتها أيضاً
نعود إلى الفشل. مع scope="module" تلقّت الاختبارات الثلاثة الاتصال نفسه. أضاف test_insert_one المستخدمة 'asha' ولم يُزلها أحد. ثم أضاف test_names_are_text المستخدم 'ravi'، وطلب الاسم الأول، فحصل على 'asha'.
أوضح علامة على هذا الخطأ اختبار ينجح حين يُشغَّل وحده. pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50sوحده ينجح؛ مع غيره يفشل. الاختبارات التي تعتمد على ما جرى قبلها أسوأ من الاختبارات البطيئة: تفشل بترتيب وتنجح بآخر، وتُهدر بعد ظهر كاملاً في كل مرة.
القاعدة: وسّع نطاق الشيء المكلف، وأبقِ نطاق الحالة ضيقاً. قسّم الـ fixture إلى اثنتين — واحدة واسعة تملك الاتصال، وأخرى بنطاق function تنظّف بعد كل اختبار:
@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 wroteالاختبارات لا تتغير — ما زالت تطلب 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.51sاتصال واحد، وثلاثة اختبارات ناجحة. يفتح sqlite3 معاملة (transaction) عند أول INSERT، وrollback() يتخلص من المعاملة بأكملها، فيبدأ كل اختبار بجدول فارغ.
لماذا نضع التنظيف في fixture بدلاً من أن يبدأ كل اختبار بـ DELETE FROM users؟ لأن التنظيف الذي يجب على كل اختبار أن يتذكره هو تنظيف سينساه اختبار ما — والاختبار الذي ينسى ليس هو الذي يفشل أبداً؛ بل التالي. أما في الـ fixture، فيحدث التنظيف بعد كل اختبار يطلب db، بما في ذلك اختبارات سيكتبها في العام القادم شخص لم يقرأ هذا الملف قط. ولماذا rollback() بدلاً من DELETE؟ لأن التراجع يلغي كل ما كتبه الاختبار، في كل الجداول، دون أن تحتاج الـ fixture إلى معرفة ماهية تلك الكتابات.
يُظهر --setup-show هذا الانقسام:
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.51sيمكن لـ fixture بنطاق function أن تطلب بحرية fixture أوسع منها، كما تطلب db هنا connection. أما الاتجاه المعاكس فغير مسموح.
ScopeMismatch — الواسع لا يطلب الضيق
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.01sفكّر فيما كان سيعنيه هذا الطلب. connection تعيش طوال التشغيل؛ وdb_name يُتخلص منها بعد أول اختبار. كانت قيمة الـ session ستتمسك بشيء لم يعد موجوداً بحسب قواعده هو. لذلك يرفض pytest — ولاحظ أنه Error أثناء التهيئة، لا Failure: الاختبار لم يُشغَّل أصلاً.
تذكر الرسالة اسمي الـ fixtures وسطريهما. والإصلاح هو نفسه دائماً: اجعل الـ fixture المطلوبة بعرض الـ fixture الطالبة على الأقل — هنا @pytest.fixture(scope="session") على db_name — أو اجعل الطالبة أضيق.
conftest.py — fixtures بلا استيراد
حين يحتاج ملف اختبار ثانٍ إلى db، فنسخ الـ fixture هو الإجابة الخاطئة. انقلها إلى ملف اسمه conftest.py بالضبط. يحمّل pytest هذا الملف من تلقاء نفسه، وكل اختبار في المجلد نفسه وما تحته يستطيع طلب الـ fixtures التي فيه — دون أي import.
مشروع فيه مجلد فرعي:
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 — دون أي استيراد:
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 == 1النطاق الآن session: اتصال واحد لكل ملفات المشروع.
تجاوز fixture لمجلد واحد
تحتاج اختبارات reports/ كلها إلى قاعدة بيانات فيها مستخدمون مسبقاً. يمكن لملف conftest.py في ذلك المجلد أن يعرّف fixture بالاسم نفسه؛ وبالنسبة لاختبارات reports/ يفوز التعريف الأقرب. وإذا طلبت اسمها هي، فإنها تتلقى نسخة المجلد الأب:
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 ===============================انظر إلى الترتيب. جرت اختبارات reports/ أولاً وأدرجت ثلاثة مستخدمين في كل مرة — ومع ذلك وجد test_starts_empty، الذي جرى بعدها، الجدولَ فارغاً. لقد تراجعت db الأب عن الصفوف المزروعة أيضاً، لأن التجاوز مبنيّ فوقها. ويُظهر --setup-show الطبقتين، وكلتاهما باسم 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(هذا هو الاختبار الأول؛ والبقية تكرر النمط نفسه.) يمتد التجاوز إلى الأسفل فقط: اختبارات المجلد الأعلى لا تراه أبداً.
--fixtures — ما الذي يمكنني طلبه هنا؟
حين تتوزع الـ fixtures على عدة ملفات conftest.py، تحتاج إلى طريقة لرؤية ما يستطيع اختبار بعينه طلبه. يعرض pytest --fixtures reports كل ما هو مرئي من ذلك المجلد. تبدأ القائمة بالـ fixtures المدمجة في pytest (الفصل التاسع) وتلك الخاصة بالإضافات المثبّتة؛ وتأتي fixtures الخاصة بك في النهاية:
------------------------ 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.كل مدخل يعطي الاسم، والنطاق (إن لم يكن function)، والملف والسطر، والأسطر الأولى من docstring. وهذا الجزء الأخير هو سبب كتابة docstring من سطر واحد لكل fixture مشتركة: إنه التوثيق الذي سيقرؤه الناس فعلاً.
autouse=True — وما يكلّفه
الـ fixture الموسومة بـ autouse=True تُستخدم في كل اختبار ضمن نطاق وصولها، سواء طلبها الاختبار أم لا. هذه الـ fixture، المضافة إلى conftest.py الأعلى، تتحقق من أن أي اختبار لا يترك صفوفاً خلفه:
@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"إنها تعمل — فالاختبار الذي يكتب عبر connection مباشرة ويتخطى التراجع يُكتشف عند التفكيك. لكن أضف الآن اختباراً لا علاقة له بقواعد البيانات:
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.50sاختبار نصي من سطر واحد صار يفتح قاعدة بيانات ويستغرق نصف ثانية. هذه كلفة autouse: إنها غير مرئية — لا شيء في test_slugify يقول إن قاعدة بيانات متورطة — وهي غير مشروطة — تعمل لكل اختبار تحت ملف conftest.py الخاص بها، بما فيها الاختبارات التي لا تحتاجها. احتفظ بـ autouse للأشياء الرخيصة والعامة حقاً، وضعها في أضيق conftest.py يغطي الاختبارات التي تحتاجها.
أي fixture تُنشأ أولاً؟
حين يحتاج اختبار إلى عدة fixtures، يرتّبها pytest وفق ثلاث قواعد:
- النطاق الأوسع أولاً. session قبل module قبل function، أياً كان الترتيب في توقيع الاختبار.
- داخل النطاق الواحد، fixtures الـ
autouseأولاً. - الاعتماديات قبل الـ fixtures التي تحتاجها.
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.01sطلب الاختبار cart قبل connection، لكن connection — بنطاق module — بُنيت أولاً، بعد اعتماديتها server. ومن بين fixtures نطاق function، جاءت audit ذات الـ autouse أولاً، ثم user التي تحتاجها cart. ويجري التفكيك بالترتيب المعاكس تماماً. وفيما عدا هذه القواعد، لا تعتمد على الترتيب: إن كان يجب أن توجد fixture قبل أخرى، فاجعلها اعتمادية بتسميتها معاملاً.
مثال متكامل
وحدة متجر صغيرة، تُختبر على قاعدة بيانات واحدة مشتركة في الذاكرة.
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 ===============================ثلاثة أمور جديرة بالملاحظة.
أولاً، حدثت التهيئة البطيئة مرة واحدة، في أول اختبار من التشغيل، ولم تحدث في أي مكان آخر. هذا هو مردود نطاق الـ session.
ثانياً، جرت اختبارات test_orders.py بعد أن أدرجت اختبارات التقارير ثمانية طلبات إجمالاً، ومع ذلك رأت إيراداً صفرياً. استبدل connection.rollback() بـ pass وشغّل من جديد، وسيظهر ثمن الحالة المشتركة فوراً:
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.52sثالثاً، لا يستورد أي ملف اختبار أي fixture. يستورد test_orders.py الوحدة shop، أي الكود المُختبَر، ولا شيء غيرها؛ وتصل الـ fixtures بالاسم من أقرب conftest.py.
عندما يحدث خطأ
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. طلبت fixture واسعة fixture أضيق منها. وسّع الـ fixture المطلوبة (db_name هنا) إلى نطاق الطالبة على الأقل، أو ضيّق الطالبة. والسطران تحت Requesting fixture stack يخبرانك أيهما أيّ.
AssertionError: assert 'asha' == 'ravi' — لكن الاختبار ينجح حين يُشغَّل وحده الحالة تتسرب بين الاختبارات عبر fixture أوسع من function. شغّل مع --setup-show لترى أي fixture مشتركة، ثم قسّمها: أبقِ الكائن المكلف واسعاً وأضف fixture بنطاق function تلغي تغييرات كل اختبار.
fixture 'report_title' not found الـ fixture موجودة، لكن ليس حيث يستطيع هذا الاختبار رؤيتها. ملف conftest.py يخدم مجلده والمجلدات التي تحته فقط — فالـ fixture في reports/conftest.py غير مرئية لاختبار في المجلد الأعلى. انقلها إلى conftest.py أعلى يغطي الاثنين. ويعرض pytest --fixtures path/to/test_file.py بالضبط ما يستطيع ذلك الملف رؤيته.
صفوف من اختبار تظهر في آخر رغم أن db تتراجع شيءٌ ما استدعى commit(). التراجع لا يلغي إلا ما لم يُثبَّت، لذا فالكود الذي يثبّت — أو الاختبار الذي يستخدم connection مباشرة بدلاً من db — يكتب بشكل دائم. وحارس مثل fixture الـ no_rows_left يحوّل ذلك إلى ERROR at teardown ... AssertionError: 1 row(s) left behind واضح للعيان.
اختبار لا يلمس أي قاعدة بيانات صار بطيئاً هناك fixture بـ autouse في مكان ما فوقه تعتمد على الـ fixture المكلفة. وتشغيل pytest --setup-show على ذلك الاختبار يسرد كل fixture استخدمها فعلاً، بما فيها تلك التي لم يطلبها قط.
from conftest import db في ملف اختبار لا تستورد من conftest.py. فـ pytest يعطيك الـ fixtures التي فيه بالاسم أصلاً، وimport conftest يجلب أي conftest.py يصادفه بايثون أولاً. في المشروع أعلاه، أدى إضافة ذلك السطر إلى test_users.py في المستوى الأعلى إلى استيراد الملف الموجود في reports/ — أي التجاوز الذي يزرع البيانات — ففشل test_starts_empty بـ assert 3 == 0. كما أن الـ fixture المستوردة تُعدّ معرّفة في ملف الاختبار نفسه، وهذا يتقدم على كل conftest.py. اطلب الـ fixtures بتسميتها معاملات، ولا شيء غير ذلك.
Step 4 of 6 — Predict
Check your understanding
عند التشغيل بـ pytest -q -s، ماذا يطبع 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': 3, 'function': 3}
- B{'module': 1, 'function': 3}
- C{'module': 1, 'function': 2}
- D{'module': 3, 'function': 2}
يعطي هذا الاختبار ScopeMismatch قبل أن يُشغَّل. ما أصغر إصلاح صحيح؟
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:"- Aوضع `test_connects` داخل صنف
- Bإعطاء `settings` المزخرف `@pytest.fixture(scope="session")`
- Cإضافة `autouse=True` إلى `connection`
- Dنقل `settings` إلى ما تحت `connection` في الملف
كلا ملفي conftest.py يعرّف db. أي db يتلقاها test_count في 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):
...- Aالموجودة في المستوى الأعلى، لأنها تُحمَّل أولاً
- Bلا هذه ولا تلك — وجود fixture-ين بالاسم نفسه خطأ
- Cالموجودة في `reports/`، لكن معاملها `db` يتلقى نفسها فيحدث تكرار لا نهائي
- Dالموجودة في `reports/`، وهي تتلقى fixture المستوى الأعلى وتضيف صفاً فوقها
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
ابنِ مشروعاً صغيراً حول library.py فيه دالتان: add_book(conn, title, author) وbooks_by(conn, author) التي تعيد قائمة مرتبة بالعناوين.
- في
conftest.pyبالمستوى الأعلى، اكتب fixture باسمconnectionبنطاق session تفتحsqlite3.connect(":memory:")، وتنشئ جدولbooks (title TEXT, author TEXT)، وتنام نصف ثانية لتمثّل خادماً بطيئاً. أعطها docstring. - أضف فوقها fixture باسم
dbبنطاق function تتراجع بعد كل اختبار. - اكتب
test_library.pyبثلاثة اختبارات، يضيف كل منها كتباً ويتحقق منbooks_by. شغّلpytest -q --durations=3وتأكد أن نصف الثانية يظهر مرة واحدة. - أنشئ
search/conftest.pyالذي يتجاوزdbلتبدأ ومعها خمسة كتب مسبقاً، وsearch/test_search.pyباختبارين يعتمدان عليها. - شغّل
pytest --setup-showوابحث عن طبقتيdbفي مخرجات اختباراتsearch/. ثم شغّلpytest --fixtures searchوتحقق من ظهور كلا الـ docstring.
ثم اكسره عمداً، مرتين. أولاً، استبدل سطر rollback() بـ pass وشغّل المجموعة كاملة — أي الاختبارات تفشل، وهل ينجح كل منها حين يُشغَّل وحده؟ ثانياً، أعطِ connection fixture بنطاق function باسم db_name لتعتمد عليها، واقرأ رسالة ScopeMismatch سطراً سطراً.
هذه الدقائق الخمس هي الفصل كله. النطاق قرار سرعة، لكن الأخطاء التي يولّدها أخطاء عزل — والطريقة الوحيدة لتعلّم اكتشافها أن تتسبب في بعضها بنفسك.
الحل
البنية:
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.51sيُظهر pytest -q --setup-show search لكل من الاختبارين الصورة نفسها التي في الفصل: SETUP F db (fixtures used: connection) يليه SETUP F db (fixtures used: db) — طبقة الأب، ثم التجاوز فوقها. ويسرد pytest --fixtures search الـ connection مع [session scope]، وكلتا fixture الـ db مع ملفها وسطرها، والـ docstrings الثلاثة كلها.
التجربة الأولى — استبدال rollback() بـ pass — تعطي:
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.52sوpytest -q test_library.py::test_one_book وحده:
. [100%]
1 passed in 0.50sجرت اختبارات search/ أولاً وتركت خمسة كتب خلفها، فوجد test_one_book عنوانين لـ Tagore بدلاً من واحد. ووحده ينجح. هذه بصمة الحالة المتسربة، تماماً كما في الفصل. أما التجربة الثانية فتحوّل الاختبارات الخمسة كلها إلى E، في كل منها ScopeMismatch نفسه الذي رأيته سابقاً، يشير هذه المرة إلى conftest.py: def connection(db_name) هي الطالبة، وdef db_name() هي الـ fixture الأضيق مما ينبغي.
سبب كل قرار:
connectionبنطاق session لأنها الشيء البطيء الوحيد، ولا شيء يفعله اختبار يغيّر الاتصال نفسه — بل الصفوف التي فيه فقط.dbبنطاق function لأن الصفوف تتغير فعلاً. مهمتها كلها التراجع بعد كل اختبار؛ ولا تحتاج الاختبارات إلى معرفة شيء عنها سوى اسمها.- الاختبارات تطلب
db، ولا تطلبconnectionأبداً. الاختبار الذي يأخذconnectionمباشرة سيتخطى التراجع ويسرّب صفوفه إلى الاختبار التالي. - تجاوز
search/يطلبdbلاconnection، فتصبح الكتب المزروعة جزءاً من عمل الاختبار نفسه، ويزيلها تراجع الأب مع كل شيء آخر. وإن تغيّر التنظيف فيdbيوماً، يرث التجاوز التغيير دون أن يُمس. test_author_match_is_exactموجود في الخطة عن قصد: فهو يثبّت أن"tagore"ليس"Tagore"، وهي حدّ سيرغب أحدهم يوماً في تغييره — وينبغي أن يغيّره عن قصد، بعد أن يخبره اختبار فاشل بذلك.- لكل fixture مشتركة docstring، لأن
pytest --fixturesهو الطريقة التي سيكتشف بها الشخص التالي ما يمكنه طلبه.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz