الـ Fixtures — تهيئة تُكتب مرة واحدة
توقّف عن تكرار التهيئة: اكتبها مرة واحدة بـ @pytest.fixture، واطلبها باسمها، واحصل على نسخة جديدة في كل اختبار، ونظّف بـ yield حتى حين يفشل الاختبار.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك سلة تسوق صغيرة في الملف cart.py:
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self.lines)
def count(self):
return sum(quantity for _, _, quantity in self.lines)وثلاثة اختبارات لها في الملف test_cart.py:
from cart import Cart
def test_total():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.total() == 895.0
def test_count():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.count() == 4
def test_add_one_more():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sالاختبارات تنجح، لكن قراءتها مرهقة. ثلاثة اختبارات، وتسعة أسطر من التهيئة (setup)، وثلاثة أسطر فقط تتحقق فعلاً من شيء ما. التهيئة متطابقة في كل اختبار، ولذلك فإن السطر الوحيد المختلف — وهو السطر الذي وُجد الاختبار من أجله — يضيع وسطها.
ولهذا التكرار ثمن يُدفع لاحقاً أيضاً. فعندما يصبح Cart() بحاجة إلى وسيط (argument)، ستضطر إلى تعديله في ثلاثة مواضع، أو ثلاثين. في الفصل السابق، أزال parametrize التكرار في مدخلات الاختبار. أما هذا الفصل فيزيل التكرار في تهيئته.
بنهاية هذا الفصل ستكون قادراً على
- كتابة fixture باستخدام
@pytest.fixtureوطلبها بذكر اسمها كمعامل (parameter) في الاختبار - شرح سبب حصول كل اختبار على نسخة جديدة خاصة به، وإثبات ذلك باستخدام قائمة
- بناء fixture اعتماداً على fixture أخرى
- كتابة fixture تستخدم
yieldوتنظّف ما خلّفته بنفسها، وتوقّع ترتيب التهيئة (setup) والتنظيف (teardown) - مراقبة إنشاء الـ fixtures باستخدام
--setup-show - قراءة الخطأ
fixture 'x' not foundوخطأ "الاستدعاء المباشر" - الاختيار بين fixture ودالة مساعدة عادية
المتطلبات المسبقة: تشغيل اختبار واحد على حالات متعددة باستخدام `parametrize`.
قبل أن تكتب الاختبار
الـ fixtures ليست شيئاً تضيفه إلى الاختبارات لاحقاً. بل هي نتيجة طبيعية لخطة مسبقة، والخطة تأتي أولاً.
ما الذي يَعِد به الكود. يَعِد Cart بثلاثة أمور: أن add() تسجّل سطراً وترفض أي كمية أقل من 1 بإطلاق ValueError؛ وأن total() هي مجموع السعر × الكمية؛ وأن count() هي مجموع الكميات. لا ملفات، ولا شبكة، ولا حالة عامة — أي لا شيء حتى الآن يحتاج إلى تراجع.
ما الذي يجب أن يكون جاهزاً. الأمر نفسه كما في كل فصل: بيئة افتراضية مثبّت فيها pytest، وأن يكون cart.py قابلاً للاستيراد من المجلد الذي تشغّل منه pytest (ضع ملف الاختبار بجانبه).
ما الحالات المطلوبة. دوّنها، وأضف عموداً واحداً يغفل عنه الناس عادةً — الحالة التي يبدأ منها الاختبار:
| الحالة | حالة البداية | الإجراء | النتيجة المتوقعة | |---|---|---|---| | المجموع العادي | قلم × 3، حقيبة × 1 | total() | 895.0 | | عدد العناصر | قلم × 3، حقيبة × 1 | count() | 4 | | سطر إضافي | قلم × 3، حقيبة × 1 | add("notebook", 60.0, 2) | total() تساوي 1015.0 | | حالة حدّية: لا شيء فيها | سلة فارغة | total() | 0 | | كمية غير صالحة | سلة فارغة | add("pen", 15.0, 0) | ValueError |
اقرأ العمود الثاني من الأعلى إلى الأسفل. ثلاثة صفوف تبدأ من السلة الممتلئة نفسها، واثنان من سلة فارغة. كل قيمة متكررة في هذا العمود هي fixture تنتظر أن تُكتب — وهنا هما empty_cart وcart المبنية عليها. أما الصف الذي يبدأ من حالة لا يشاركه فيها أحد، فلا يحتاج إلى fixture إطلاقاً؛ بل يبني حالته بنفسه.
واطرح سؤالاً إضافياً على كل حالة بداية: هل يؤدي إنشاؤها إلى تغيير أي شيء خارج الاختبار؟ إنشاء Cart جديدة لا يغيّر شيئاً. أما تسجيل رمز خصم في قاموس عام، أو فتح اتصال، أو كتابة ملف — فكلها تغيّر شيئاً، وهذا ما يخبرك بأن الـ fixture تحتاج إلى تنظيف (yield، لاحقاً في هذا الفصل).
ما الذي لا ينبغي اختباره. لا تكتب اختبارات تثبت أن pytest يبني fixture جديدة لكل اختبار؛ فهذا وعدٌ من pytest نفسه، وهذا الفصل يعرضه مرة واحدة حتى تتمكن من الوثوق به. لكن اختبر عملية التنظيف الخاصة بك عندما تعيد حالة عامة إلى وضعها — فهذا الكود كودك أنت، وقد يكون خاطئاً.
بقية الفصل تحوّل هذه الخطة إلى كود.
أول fixture
انقل التهيئة إلى دالة، وضع @pytest.fixture فوقها:
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 15.0, 3)
c.add("bag", 850.0)
return c
def test_total(cart):
assert cart.total() == 895.0
def test_count(cart):
assert cart.count() == 4
def test_add_one_more(cart):
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sلا يوجد في الاختبارات أي استدعاء لـ cart(). كل اختبار يملك ببساطة معاملاً اسمه cart، وهذا هو الطلب بأكمله.
قبل تشغيل أي اختبار، ينظر pytest في أسماء معاملاته. ولكل اسم منها يبحث عن fixture تحمل الاسم نفسه، فيشغّلها، ثم يمرر ما أعادته كوسيط للاختبار. الاختبار يحدد ماذا يحتاج، لكنه لا يبنيه بنفسه. لهذا الترتيب اسم — حقن الاعتماديات (dependency injection) — لكن الآلية لا تتعدى "مطابقة اسم المعامل مع اسم الـ fixture".
صار الاختبار الآن يُقرأ كسطر واحد يعبّر عن الغرض منه. والتهيئة موجودة في مكان واحد، فأي تغيير في Cart() هو تغيير في دالة واحدة.
كل اختبار يحصل على نسخة جديدة
يضيف test_add_one_more دفتراً إلى السلة. فهل رأت الاختبارات التي جرى تشغيلها بعده سلةً فيها دفتر؟ لا — ومن المفيد أن ترى ذلك بوضوح، لأن هذه هي الخاصية التي تجعل الـ fixtures آمنة.
لنبدأ بالنسخة التي لا تستخدم fixture. قائمة في أعلى الوحدة (module)، يتشاركها اختباران:
ITEMS = ["pen", "bag"]
def test_add_item():
ITEMS.append("notebook")
assert len(ITEMS) == 3
def test_starts_with_two():
assert len(ITEMS) == 2pytest -q
.F [100%]
=================================== FAILURES ===================================
_____________________________ test_starts_with_two _____________________________
def test_starts_with_two():
> assert len(ITEMS) == 2
E AssertionError: assert 3 == 2
E + where 3 = len(['pen', 'bag', 'notebook'])
test_shared.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_shared.py::test_starts_with_two - AssertionError: assert 3 == 2
1 failed, 1 passed in 0.01sالاختبار الثاني صحيح؛ لكنه فشل بسبب ما فعله الاختبار الأول. شغّله وحده وسينجح:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sالاختبار الذي تتوقف نتيجته على الاختبارات التي سبقته في التشغيل هو من أكثر الأخطاء كلفةً في مجموعة الاختبارات، لأنه لا يظهر إلا أحياناً.
والآن الاختباران نفساهما، لكن القائمة تأتي من fixture. يُظهر print داخل الـ fixture متى يجري تشغيلها؛ ويطلب الخيار -s من pytest ألا يلتقط المخرجات المطبوعة، حتى نتمكن من رؤيتها:
import pytest
@pytest.fixture
def items():
print("building a new list")
return ["pen", "bag"]
def test_add_item(items):
items.append("notebook")
assert len(items) == 3
def test_starts_with_two(items):
assert len(items) == 2pytest -q -s
building a new list
.building a new list
.
2 passed in 0.01sتظهر العبارة "building a new list" مرتين. فقد جرى تشغيل دالة الـ fixture مرة واحدة لكل اختبار طلبها، وحصل كل اختبار على قائمة لم يمسّها أحد غيره. هذا هو السلوك الافتراضي، وله اسم، وهو نطاق الدالة (function scope): تعيش قيمة الـ fixture طوال مدة دالة اختبار واحدة. يوضح الفصل الثامن كيف تغيّر ذلك، ولماذا لا ينبغي لك في العادة أن تفعل.
يمكن للـ fixtures أن تستخدم fixtures أخرى
يمكن للـ fixture أن تطلب fixture أخرى بالطريقة نفسها تماماً التي يطلبها بها الاختبار — بذكر اسمها كمعامل:
import pytest
from cart import Cart
@pytest.fixture
def empty_cart():
return Cart()
@pytest.fixture
def cart(empty_cart):
empty_cart.add("pen", 15.0, 3)
empty_cart.add("bag", 850.0)
return empty_cart
def test_new_cart_is_empty(empty_cart):
assert empty_cart.total() == 0
def test_total(cart):
assert cart.total() == 895.0pytest -q
.. [100%]
2 passed in 0.01sتُبنى cart فوق empty_cart، وبذلك يستطيع كل اختبار أن يطلب مستوى التهيئة الذي يحتاجه. لا يستدعي أي من الاختبارين شيئاً؛ بل يتتبّع pytest الأسماء، من test_total إلى cart ومن cart إلى empty_cart، ويبنيها بالترتيب الذي تقتضيه هذه السلسلة.
رؤية ما يحدث — --setup-show
لست مضطراً إلى إضافة استدعاءات print لمعرفة الـ fixtures التي جرى تشغيلها. فالخيار --setup-show يطبع كل عملية setup وteardown:
pytest -q --setup-show
SETUP F empty_cart
test_cart.py::test_new_cart_is_empty (fixtures used: empty_cart) .
TEARDOWN F empty_cart
SETUP F empty_cart
SETUP F cart (fixtures used: empty_cart)
test_cart.py::test_total (fixtures used: cart, empty_cart) .
TEARDOWN F cart
TEARDOWN F empty_cart
2 passed in 0.01sاقرأها من الأعلى. يعني الحرف F نطاق الدالة — أي أنها تُبنى لاختبار واحد ثم تُتلف بعده. تجري تهيئة empty_cart مرتين، مرة لكل اختبار، وهذه هي النسخة الجديدة التي رأيناها في القسم السابق. وحيثما تُستخدم cart، تجري تهيئة empty_cart أولاً، لأن cart تعتمد عليها. أما عمليات التنظيف فتأتي بترتيب معاكس لترتيب التهيئة: آخر ما بُني هو أول ما يُزال.
(إذا كانت لديك إضافات (plugins) مثبتة، فقد تظهر أسطر إضافية للـ fixtures الخاصة بها. تلك الأسطر تخصها هي، لا تخصك أنت.)
حتى الآن لم يفعل الـ teardown شيئاً مرئياً، لأنه لم يكن هناك ما يجب التراجع عنه. وهذا ما سيتغير الآن.
yield — التهيئة، ثم التنظيف
بعض عمليات التهيئة تترك خلفها شيئاً يجب التراجع عنه: اتصالاً يجب إغلاقه، أو ملفاً يجب حذفه، أو إعداداً عاماً يجب إعادته إلى حاله. لهذا الغرض، تستخدم الـ fixture الكلمة yield بدلاً من return:
import pytest
@pytest.fixture
def connection():
print("\nconnection: open")
conn = {"orders": []}
yield conn
print("connection: close")
def test_starts_empty(connection):
print("test: running")
assert connection["orders"] == []pytest -q -s
connection: open
test: running
.connection: close
1 passed in 0.01sكل ما يسبق yield هو التهيئة. والقيمة التي تلي yield هي ما يستلمه الاختبار. ثم تتوقف الـ fixture مؤقتاً — فيجري تشغيل الاختبار — وعندما ينتهي الاختبار، يستأنف pytest تنفيذ الـ fixture من بعد yield، وما تبقى منها هو الـ teardown. (تظهر النقطة . قبل "connection: close" لأن نتيجة الاختبار تُطبع لحظة انتهائه، أي قبيل بدء التنظيف مباشرة.)
يوجد التنظيف في الدالة نفسها التي توجد فيها التهيئة، على بعد أسطر قليلة أسفلها. ولا يمكنك أن تضيف أحدهما وتنسى الآخر دون أن يكون ذلك ظاهراً للعيان.
ومع وجود fixtureين تستخدمان yield، تعتمد إحداهما على الأخرى، يكون الترتيب هو ذاك الذي ألمح إليه --setup-show:
import pytest
@pytest.fixture
def connection():
print("\n1. connection: open")
conn = {"orders": []}
yield conn
print("5. connection: close")
@pytest.fixture
def saved_order(connection):
print("2. saved_order: insert")
connection["orders"].append("A-100")
yield "A-100"
connection["orders"].remove("A-100")
print("4. saved_order: delete")
def test_order_is_saved(connection, saved_order):
print("3. test: running")
assert saved_order in connection["orders"]pytest -q -s
1. connection: open
2. saved_order: insert
3. test: running
.4. saved_order: delete
5. connection: close
1 passed in 0.01sتسير التهيئة من أسفل سلسلة الاعتماديات صعوداً؛ ويعود التنظيف نزولاً. وهذا هو الترتيب الوحيد الذي ينجح: فـ saved_order تحذف صفّها عبر الاتصال، ولذلك يجب أن يظل الاتصال مفتوحاً حين تفعل ذلك.
التنظيف يجري حتى عندما يفشل الاختبار
لماذا نتكلّف كتابة fixture من أجل التنظيف، بينما يستطيع الاختبار ببساطة أن ينظّف في نهايته؟ جرّب ذلك. تمثّل OPEN أي شيء يمكن أن يتركه الاختبار خلفه — اتصالاً مفتوحاً، أو ملفاً مؤقتاً، أو إعداداً جرى تغييره:
OPEN = []
def test_broken():
OPEN.append("connection")
assert OPEN == ["A-100"]
OPEN.remove("connection")
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_body.py::test_broken - AssertionError: assert ['connec...
FAILED test_cleanup_in_body.py::test_nothing_left_open - AssertionError: asse...
2 failed in 0.01s(يُخفي الخيار --tb=no تتبّعات الأخطاء (tracebacks) ولا يُبقي إلا الملخص.) خطأ واحد، وفشلان. فالـ assert الفاشل يطلق استثناءً، وإطلاق الاستثناء يُنهي الاختبار في مكانه، ولذلك لم يُنفَّذ سطر التنظيف الذي يليه قط. بقي الاتصال في OPEN، وفشل الاختبار التالي — الذي كان صحيحاً تماماً — بسبب ما ورثه. وفي مجموعة اختبارات كبيرة، هذا الفشل الثاني هو الذي قد تقضي عصراً كاملاً في تتبّعه.
والآن العمل نفسه، لكن مع وضع التنظيف بعد yield:
import pytest
OPEN = []
@pytest.fixture
def connection():
OPEN.append("connection")
yield "connection"
OPEN.remove("connection")
def test_broken(connection):
assert OPEN == ["A-100"]
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
F. [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_fixture.py::test_broken - AssertionError: assert ['con...
1 failed, 1 passed in 0.01sخطأ واحد، وفشل واحد. لا يزال الاختبار المعطوب يظهر في التقرير — فالـ fixture لا تُخفي شيئاً — لكن الفوضى التي خلّفها لم تصل إلى الاختبار التالي.
هذا هو السبب، ومنه تنبع القاعدة: لا يوضع التنظيف أبداً في نهاية جسم الاختبار. بل يوضع بعد yield في fixture.
fixture أم دالة عادية؟
ليست الـ fixture الطريقة الوحيدة لمشاركة التهيئة. فالدالة العادية — ولتكن cart_with(*lines)، التي تبني سلة من أي أسطر تُعطى لها — تؤدي الغرض أيضاً، والمثال المتكامل أدناه يستخدم واحدة منها.
وإليك الفرق الذي يحسم الاختيار: الاختبار يستدعي الدالة المساعدة، ويمكنه أن يمرر لها وسائط. أما الـ fixture فلا يمكن للاختبار أن يستدعيها؛ بل يستطيع فقط أن يذكر اسمها. والـ fixture تنتج دائماً الشيء نفسه لكل اختبار يطلبها.
لذلك:
- استخدم fixture عندما تحتاج اختبارات كثيرة إلى حالة البداية نفسها، وخصوصاً عندما تحتاج هذه الحالة إلى تنظيف بعد ذلك. فالـ fixture وحدها هي التي تحصل على teardown مضمون.
- استخدم دالة مساعدة عندما يحتاج كل اختبار إلى كائن مختلف قليلاً — سلة بهذه الأسطر، أو مستخدم بذلك الاسم. فالوسائط هي ما وُجدت الدوال من أجله.
ويتكامل الاثنان جيداً: إذ يمكن للـ fixture أن تستدعي دالة مساعدة، كما يفعل المثال التالي. (هناك أيضاً طريقة لإنشاء fixture تعيد دالة — وهو نمط "المصنع (factory)" في الفصل الثاني عشر. لست بحاجة إليه بعد.)
مثال تطبيقي متكامل
متجر تُحفظ فيه رموز الخصم في قاموس على مستوى الوحدة (module). تسجيل رمز ما يغيّر الحالة العامة، ولذلك فإن الاختبار الذي يسجّل رمزاً يجب أن يزيله مرة أخرى.
shop.py:
DISCOUNTS = {}
def register_discount(code, percent):
DISCOUNTS[code] = percent
def remove_discount(code):
del DISCOUNTS[code]
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self, code=None):
amount = sum(price * quantity for _, price, quantity in self.lines)
if code is not None:
amount = amount * (100 - DISCOUNTS[code]) / 100
return round(amount, 2)test_shop.py:
import pytest
from shop import Cart, register_discount, remove_discount
def cart_with(*lines):
# A plain helper: it takes arguments, so each test can ask for its own cart.
cart = Cart()
for name, price, quantity in lines:
cart.add(name, price, quantity)
return cart
@pytest.fixture
def cart():
# The cart most tests share. Built fresh for every test that asks.
return cart_with(("pen", 15.0, 3), ("bag", 850.0, 1))
@pytest.fixture
def summer_code():
# Changes global state, so it must undo that change afterwards.
register_discount("SUMMER", 10)
yield "SUMMER"
remove_discount("SUMMER")
def test_total(cart):
assert cart.total() == 895.0
def test_summer_discount(cart, summer_code):
assert cart.total(summer_code) == 805.5
def test_code_is_gone_afterwards(cart):
with pytest.raises(KeyError):
cart.total("SUMMER")
def test_small_cart(summer_code):
cart = cart_with(("pen", 15.0, 2))
assert cart.total(summer_code) == 27.0
def test_zero_quantity_is_refused():
with pytest.raises(ValueError):
Cart().add("pen", 15.0, 0)pytest -q --setup-show
SETUP F cart
test_shop.py::test_total (fixtures used: cart) .
TEARDOWN F cart
SETUP F cart
SETUP F summer_code
test_shop.py::test_summer_discount (fixtures used: cart, summer_code) .
TEARDOWN F summer_code
TEARDOWN F cart
SETUP F cart
test_shop.py::test_code_is_gone_afterwards (fixtures used: cart) .
TEARDOWN F cart
SETUP F summer_code
test_shop.py::test_small_cart (fixtures used: summer_code) .
TEARDOWN F summer_code
test_shop.py::test_zero_quantity_is_refused .
5 passed in 0.01sهناك ثلاثة أمور جديرة بالملاحظة.
أولاً، كل اختبار يطلب فقط ما يستخدمه. أراد test_small_cart سلة مختلفة، ولذلك لم يطلب cart إطلاقاً؛ بل استدعى الدالة المساعدة بأسطره الخاصة — ويؤكد --setup-show أن cart لم تُبنَ من أجله قط. أما test_zero_quantity_is_refused فلا يحتاج إلا إلى سلة فارغة واستدعاء واحد، ولذلك لا يستخدم أي fixture؛ وهذا هو صف "حالة البداية التي لا يشاركه فيها أحد" من الخطة.
ثانياً، test_code_is_gone_afterwards هو اختبار للـ fixture نفسها. فهو يجري بعد test_summer_discount ويتحقق من أن SUMMER لم يعد موجوداً. ولكي ترى أهميته، غيّر summer_code لتستخدم return "SUMMER" بدلاً من yield وسطر التنظيف:
pytest -q --tb=no
..F.. [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_code_is_gone_afterwards - Failed: DID NOT RAISE Key...
1 failed, 4 passed in 0.01sلقد تسرّب الرمز من الاختبار الذي سجّله إلى الاختبار التالي. ولولا هذا التحقق، لكان التسرب صامتاً.
ثالثاً، الدالة المساعدة والـ fixture تعملان معاً. فـ cart_with تعرف كيف تُبنى السلة؛ بينما تقرر الـ fixture المسماة cart أي سلة تتشاركها معظم الاختبارات.
الأخطاء الشائعة وحلولها
خطأ fixture 'crat' not found
_________________________ ERROR at setup of test_empty _________________________
file .../test_typo.py, line 11
def test_empty(crat):
E fixture 'crat' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.اسم أحد المعاملات لم يطابق أي fixture. فإما أنه مكتوب بشكل خاطئ — هنا crat بدلاً من cart، الموجودة أمامك في القائمة — وإما أن الدالة ينقصها سطر @pytest.fixture، وفي هذه الحالة لن تظهر في القائمة أصلاً. لاحظ وجود E وERROR، لا F وFAILED: فالاختبار لم يبدأ قط.
خطأ Fixture "cart" called directly
Fixture "cart" called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.كتب الاختبار cart() في جسمه. تُطلب الـ fixture بذكر اسمها كمعامل، ولا تُستدعى أبداً. وإذا كنت تريد فعلاً استدعاءها بوسائطك الخاصة، فما تحتاجه في الحقيقة هو دالة مساعدة.
ظهور ERROR at setup of test_total بدلاً من فشل عادي أُطلق الاستثناء داخل الـ fixture، قبل تشغيل الاختبار — على سبيل المثال ValueError: quantity must be at least 1: 0 الناتج عن استدعاء خاطئ لـ add() في التهيئة. يعني الحرف E أن "التحضير تعطّل"؛ بينما يعني F أن "الادعاء كان خاطئاً". ابحث في الـ fixture أولاً.
خطأ fixture function has more than one 'yield' الـ fixture تستخدم yield مرة واحدة فقط. التهيئة تأتي فوق yield، والتنظيف يأتي تحتها؛ وإذا احتجت إلى قيمتين، فأعد tuple عبر yield أو اكتب fixtureين.
اختبار ينجح وحده ويفشل مع الاختبارات الأخرى هناك شيء مشترك بين الاختبارات — قائمة أو قاموس على مستوى الوحدة، أو متغير عام غيّرته fixture ولم تُعِده إلى حاله. ابنِه داخل fixture، وإذا كان حالة عامة، فتراجع عن التغيير بعد yield.
التنظيف لم يجرِ التنظيف موجود في جسم الاختبار بعد assert فاشل، أو أن الـ fixture تستخدم return. انقله إلى fixture تستخدم yield، بعد yield.
Step 4 of 6 — Predict
Check your understanding
كلا الاختبارين يطلبان الـ fixture نفسه، وكلاهما يضيف عنصراً إلى القائمة. ماذا يقول السطر الأخير من pytest -q؟
import pytest
@pytest.fixture
def basket():
return []
def test_one(basket):
basket.append("pen")
assert basket == ["pen"]
def test_two(basket):
basket.append("bag")
assert basket == ["bag"]- A1 failed, 1 passed — يرى test_two القائمة ['pen', 'bag']
- B2 failed
- C2 passed
- D1 passed, 1 error
شُغّل هذا بالأمر pytest -q -s. بتجاهل النقاط والملخص الخاصين بـ pytest، بأي ترتيب تُطبع الأسطر الثلاثة؟
import pytest
@pytest.fixture
def db():
print("open")
yield "db"
print("close")
def test_query(db):
print("query")- Aopen close query
- Bquery open close
- Copen query
- Dopen query close
هذا الاختبار يفشل. أين المشكلة؟
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- Aيجب أن يعيد الـ fixture صفاً (tuple)
- Bالاختبار يستدعي الـ fixture بنفسه؛ والصواب أن يأخذ `cart` معاملاً (parameter)
- Cيحتاج الـ fixture إلى `yield` بدلاً من `return`
- Dلا يمكن استخدام `len()` على 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 وفيه قائمة على مستوى الوحدة LOG = [] والصنف Library الذي يحتوي على: add(title)؛ وborrow(title)، التي تطلق ValueError إذا لم يكن العنوان على الرف، وإلا فإنها تزيله منه وتضيف "borrow <title>" إلى LOG؛ وgive_back(title)؛ وavailable()، التي تعيد قائمة مرتبة بالعناوين الموجودة على الرف.
ثم اكتب test_library.py. خطّط له أولاً — جدول فيه عمود حالة البداية، كما في بداية هذا الفصل — ثم اكتب:
- fixture باسم
libraryتعيد كائنLibraryفيه ثلاثة كتب، وثلاثة اختبارات على الأقل تستخدمها — أحدها يستعير كتاباً، حتى يتمكن اختبار لاحق من إثبات أن الرف ممتلئ من جديد - fixture باسم
borrowedتعتمد علىlibrary، وتستعير كتاباً واحداً وتعيد عنوانه، مع اختبارين يستخدمانها - fixture تستخدم
yieldباسمclean_logتفرغLOGقبل الاختبار ثم مرة أخرى بعده؛ واختبار واحد يستخدمها، واختبار آخر يليه يتحقق من أنLOG == [] - دالة مساعدة عادية
library_with(*titles)، تستخدمهاlibraryويستخدمها اختبار واحد يحتاج إلى رف فارغ
شغّل pytest -q، ثم pytest -q --setup-show، وطابق كل سطر مع ما كنت تتوقعه.
وأخيراً، اكسر الكود عمداً: في clean_log، استبدل yield والسطر الذي يليه بـ return LOG. أي اختبار يفشل، وهل هو الاختبار الذي استخدم الـ fixture؟
الحل
الخطة أولاً:
| الحالة | حالة البداية | الإجراء | النتيجة المتوقعة | |---|---|---|---| | ثلاثة كتب على الرف | Dune وEmma وUlysses | available() | الثلاثة كلها، مرتبة | | الاستعارة تزيل الكتاب | الحالة نفسها | borrow("Dune") | Emma وUlysses | | لا تسرّب بين الاختبارات | الحالة نفسها | — | لا تزال ثلاثة | | لا يمكن الاستعارة مرتين | الحالة نفسها، مع استعارة Emma | borrow("Emma") | ValueError | | الإرجاع | الحالة نفسها، مع استعارة Emma | give_back("Emma") | Emma على الرف | | الاستعارة تُسجَّل | الحالة نفسها، وLOG فارغة | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | تنظيف السجل | بعد الاختبار السابق | — | LOG == [] | | حالة حدّية: رف فارغ | لا كتب | available() | [] |
تتكرر حالتا بداية — الرف ذو الكتب الثلاثة، والرف الذي استُعيرت منه Emma — ولذلك أصبحتا fixtures. أما السجل الفارغ فلا يُحتاج إليه إلا مرة واحدة، لكنه يمسّ حالة عامة يجب إعادتها إلى وضعها بعد ذلك، والـ fixture وحدها هي التي تضمن ذلك؛ لذا فهو fixture أيضاً. والرف الفارغ لا يُحتاج إليه إلا مرة واحدة ولا يترك شيئاً خلفه، ولذلك يستخدم الدالة المساعدة.
library.py:
LOG = []
class Library:
def __init__(self):
self.shelf = set()
def add(self, title):
self.shelf.add(title)
def borrow(self, title):
if title not in self.shelf:
raise ValueError(f"not on the shelf: {title}")
self.shelf.remove(title)
LOG.append(f"borrow {title}")
def give_back(self, title):
self.shelf.add(title)
def available(self):
return sorted(self.shelf)test_library.py:
import pytest
from library import LOG, Library
def library_with(*titles):
# Helper: each caller chooses its own shelf.
library = Library()
for title in titles:
library.add(title)
return library
@pytest.fixture
def library():
return library_with("Dune", "Emma", "Ulysses")
@pytest.fixture
def borrowed(library):
library.borrow("Emma")
return "Emma"
@pytest.fixture
def clean_log():
LOG.clear()
yield LOG
LOG.clear()
def test_three_books_on_the_shelf(library):
assert library.available() == ["Dune", "Emma", "Ulysses"]
def test_borrow_removes_the_book(library):
library.borrow("Dune")
assert library.available() == ["Emma", "Ulysses"]
def test_next_test_still_sees_three(library):
assert len(library.available()) == 3
def test_cannot_borrow_twice(library, borrowed):
with pytest.raises(ValueError):
library.borrow(borrowed)
def test_give_back_returns_it(library, borrowed):
library.give_back(borrowed)
assert borrowed in library.available()
def test_borrow_is_logged(library, clean_log):
library.borrow("Ulysses")
assert clean_log == ["borrow Ulysses"]
def test_log_is_empty_afterwards():
assert LOG == []
def test_empty_library():
library = library_with()
assert library.available() == []pytest -q
........ [100%]
8 passed in 0.01sمع --setup-show سترى SETUP F library مرة واحدة لكل اختبار من الاختبارات الستة التي تستخدمها، وسترى borrowed تُهيَّأ بعد library وتُزال قبلها، ولن ترى أي أسطر خاصة بالـ fixtures إطلاقاً في الاختبارين الأخيرين.
وأما التجربة، مع وضع return LOG مكان yield:
pytest -q --tb=no
......F. [100%]
=========================== short test summary info ============================
FAILED test_library.py::test_log_is_empty_afterwards - AssertionError: assert...
1 failed, 7 passed in 0.01sوالقرارات، واحداً تلو الآخر:
libraryتستخدم return، لا yield. فإنشاءLibraryجديدة لا يغيّر شيئاً خارج الاختبار، ولذلك لا يوجد ما يجب التراجع عنه.yieldمخصصة للـ fixtures التي لديها ما تنظّفه، وليست مسألة أسلوب.test_next_test_still_sees_threeموجود عن قصد. فهو يجري مباشرة بعد اختبار استعارDune، وينجح لأنlibraryبُنيت من جديد. ويمكنك أن تعدّ ذلك في--setup-show: سطرSETUP F libraryواحد لكل اختبار.borrowedتعيد العنوان. تستخدم الاختباراتborrowedبدلاً من تكرار"Emma"، فإذا غيّرت الـ fixture الكتاب الذي تستعيره، تتبعها الاختبارات تلقائياً. ولأنborrowedتعتمد علىlibrary، فإن كلا الاختبارين اللذين يطلبان الاثنتين يحصلان على المكتبة نفسها — تلك التي أُخذت منها Emma.clean_logتُفرغ السجل على جانبيyield. قبله، لأن اختبارات أخرى تستدعيborrow()— مثلtest_borrow_removes_the_bookوالـ fixture المسماةborrowed— تكون قد كتبت فيLOGبالفعل؛ ويجب أن يبدأ الاختبار من حالة معروفة. وبعده، حتى لا يتسرب شيء إلى الاختبار التالي.- *التجربة تفشل في الاختبار الآخر.* لا يزال
test_borrow_is_loggedينجح؛ بينما يفشلtest_log_is_empty_afterwards، الذي لم يرتكب أي خطأ. وهكذا يبدو غياب التنظيف دائماً: يظهر الفشل في مكان آخر. وهذا بالضبط سبب احتواء الحل على اختبار يتحقق من التنظيف. library_withدالة مساعدة، لا fixture، لأن الاختبار الوحيد الذي يستخدمها يريد رفاً مختلفاً، والدالة وحدها هي التي تستطيع استقبال وسائط. وتستدعيهاlibraryأيضاً، وبذلك تبقى معرفة طريقة ملء المكتبة في مكان واحد.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz