assert وقراءة تقارير الفشل
كيف يرى pytest ما بداخل assert عادية، وكيف تقرأ تقرير الفشل سطراً سطراً، وأي مقارنة تكتبها ليخبرك التقرير بأكبر قدر ممكن.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك تحققاً مكتوباً ببايثون العادي، في ملف باسم check.py:
def full_name(first, last):
return f"{first} {last}".title()
assert full_name("ada", "lovelace") == "Ada Lovelace "Traceback (most recent call last):
File "/home/you/shop/check.py", line 5, in <module>
assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorتخبرك بايثون بأن الادعاء كان خاطئاً، ولا شيء غير ذلك. ما الذي أعادته full_name فعلاً؟ سيتعين عليك إضافة print، ثم التشغيل مرة أخرى، ثم النظر.
ضع السطر نفسه داخل اختبار، في الملف test_names.py، وشغّل pytest -q:
from names import full_name
def test_full_name():
assert full_name("ada", "lovelace") == "Ada Lovelace "F [100%]
=================================== FAILURES ===================================
________________________________ test_full_name ________________________________
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
E AssertionError: assert 'Ada Lovelace' == 'Ada Lovelace '
E
E - Ada Lovelace
E ? -
E + Ada Lovelace
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError: assert 'Ada Lovelace' ...
1 failed in 0.01sالآن يمكنك رؤية القيمتين معاً، مع علامة تحت الحرف الوحيد المختلف: مسافة زائدة في نهاية النص المتوقع. الدالة كانت صحيحة؛ والاختبار هو الذي كان خاطئاً.
قيمة الاختبار الفاشل تساوي تماماً قيمة ما يخبرك به. يتناول هذا الفصل قراءة كل ما يخبرك به pytest، وكتابة عبارات assert تمنحه شيئاً يستحق أن يُقال.
بنهاية هذا الفصل ستكون قادراً على
- شرح ماهية إعادة كتابة التأكيدات (assertion rewriting)، وأين تنطبق وأين لا تنطبق
- قراءة تقرير الفشل سطراً بسطر:
>وEوwhereوfile:lineوالملخص القصير - الاختيار بين
==وinوis Noneوis Trueوassert xالمجردة - قراءة الفروقات (diffs) في القوائم (list) والقواميس (dict) والنصوص، وطلب الفرق الكامل باستخدام
-vv - إضافة رسالة إلى
assert، وتجنب الصف (tuple) الذي يكون صحيحاً دائماً - التحكم في شكل التقرير باستخدام
--tb=shortو--tb=lineو--tb=noو-l
المتطلبات المسبقة: تثبيت pytest واختبارك الأول.
قبل أن تكتب الاختبار
عبارة assert هي وعد مكتوب. قبل أن تكتب واحدة، هناك ثلاثة أسئلة تحتاج إلى إجابات، ولا يتعلق أي منها بـ pytest.
ما الذي تعد به بالضبط؟ خذ الدالة التي يُختتم بها هذا الفصل، summarise(lines)، والتي تقرأ أسطر فاتورة مثل "pen, 12, 15.0". عقدها في جملة واحدة: تتخطى الأسطر الفارغة وتعيد قاموساً (dict) يحتوي على عدد العناصر، وأسمائها بالترتيب الذي وردت به، والإجمالي مقرّباً إلى منزلتين عشريتين. كل assert ستكتبها لاحقاً هي بند واحد من بنود تلك الجملة. إذا لم تستطع قول الجملة، فأنت لست مستعداً لكتابة الاختبار — إذ ستنتهي باختبار ما يصادف أن يفعله الكود.
ما الذي يجب أن يكون جاهزاً؟ القليل جداً في هذا الفصل: البيئة الافتراضية من الفصل الأول مع تثبيت pytest فيها (أي أن python -m pytest --version يستجيب)، وأن تكون الوحدة قيد الاختبار قابلة للاستيراد من المكان الذي تشغّل منه pytest — هنا يقع invoice.py بجوار test_invoice.py وتشغّل pytest من ذلك المجلد. لا ملفات، ولا شبكة، ولا fixtures.
ما الحالات، وما المتوقع من كل منها؟ احسب القيم المتوقعة يدوياً، قبل تشغيل الكود. ناتج 12 × 15.0 + 2 × 850.0 هو 1880.0؛ وهذا الرقم يأتي من الحساب، لا مما طبعته summarise. إذا نسخت المخرجات إلى الاختبار بدلاً من ذلك، فإن الاختبار يسجل سلوك اليوم بما فيه من أخطاء، وسيدافع عن الخطأ من تلك اللحظة فصاعداً.
| الحالة | المدخل | المتوقع | الـ assert التي ستكتبها | | --- | --- | --- | --- | | سطر واحد، مع مسافات حول الاسم | " pen , 12, 15.0" | {"name": "pen", "qty": 12, "price": 15.0} | == على القاموس بأكمله | | تخطي الأسطر الفارغة | ["pen, 12, 15.0", "", "bag, 2, 850.0"] | العدد هو 2 | == | | الملخص بأكمله | الأسطر الثلاثة نفسها | {"count": 2, "names": ["pen", "bag"], "total": 1880.0} | == على القاموس بأكمله | | البحث عن عنصر موجود | "pen" | سجل، وليس None | is not None | | البحث عن عنصر غير موجود | قائمة فارغة، "pen" | None | is None | | الإجمالي منطقي | الأسطر الثلاثة نفسها | أكبر من 0 | > مع رسالة |
العمود الأخير هو موضوع هذا الفصل: لكل وعد، المقارنة التي تعبّر عنه بأكبر قدر من الدقة وتفشل بأكثر التقارير فائدة.
ما الذي لا يجب اختباره. أن str.split تقسّم، وأن round تقرّب، وأن int("12") تساوي 12 — فبايثون تختبر هذه الأمور بالفعل. كما أن الصياغة الدقيقة لمخرجات pytest ليست من شأنك أن تختبرها. السطر المشوّه مثل "pen, twelve, 15.0" ينتمي إلى الخطة، لكن التأكد من إطلاق خطأ يحتاج إلى pytest.raises، وهو موضوع الفصل الرابع؛ دوّن الصف الآن، واكتب الاختبار حينها.
كيف يرى pytest ما بداخل assert
تتخلص بايثون العادية من القيم في اللحظة التي تفشل فيها assert. أما pytest فيحتفظ بها لأنه يغيّر ملف الاختبار قبل تشغيله.
عندما يستورد pytest وحدة اختبار، فإنه يعثر على كل عبارة assert ويعيد كتابتها إلى كود يخزّن كل قيمة وسيطة — ناتج full_name(...)، والنص على الجانب الأيمن — قبل التحقق من الشرط. وعندما يفشل التحقق، تصبح تلك القيم المخزنة هي التقرير. هذه هي إعادة كتابة التأكيدات (assertion rewriting)، وهي السبب في أن pytest لا يحتاج إلى assertEqual أو assertIn: فعبارة assert عادية مع == عادية تكفي.
عطّلها لترى ما تقدمه لك. الأمر pytest -q --assert=plain على الاختبار نفسه:
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionErrorعدنا إلى كلمة واحدة مجردة. تنطبق إعادة الكتابة على ملفات الاختبار (test_*.py و *_test.py) وعلى conftest.py. وهي لا تنطبق على وحداتك الأخرى، وهذا يصبح مهماً بمجرد أن تضع assert داخل دالة مساعدة — يوضح قسم "عندما يتعطل شيء ما" كيف يبدو ذلك.
قراءة تقرير الفشل، سطراً بسطر
وحدة صغيرة، cart.py:
def subtotal(items):
return sum(price * qty for price, qty in items)
def total(items, discount):
return subtotal(items) - discountوالملف test_cart.py، حيث يتوقع الاختبار الثاني رقماً خاطئاً:
from cart import subtotal, total
def test_subtotal():
assert subtotal([(15.0, 2), (60.0, 1)]) == 90.0
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
assert total(items, 10) == 70.0شغّل pytest بدون خيارات:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 2 items
test_cart.py .F [100%]
=================================== FAILURES ===================================
___________________________ test_total_with_discount ___________________________
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
========================= 1 failed, 1 passed in 0.01s ==========================اقرأه من الأعلى:
test_cart.py .F— حرف واحد لكل اختبار، بالترتيب..يعني النجاح، وFيعني الفشل.____ test_total_with_discount ____— عنوان حالة فشل واحدة. كل اختبار فاشل يحصل على قسم خاص به.- أسطر المصدر — جسم الاختبار حتى السطر الذي فشل.
>— السطر الدقيق الذي أطلق الخطأ. كل ما فوقه نُفّذ دون أي اعتراض.E assert 80.0 == 70.0— عبارةassertمرة أخرى، مع استبدال كل تعبير بقيمته. الجانب الأيسر كان80.0، والجانب الأيمن70.0.E + where 80.0 = total(...)— مصدر القيمة. في حالة استدعاء دالة، يعرض pytest الاستدعاء مع تعبئة وسائطه. والاستدعاءات المتداخلة تنتج أسطرwhereمتداخلة، كل منها بإزاحة أكبر.test_cart.py:10: AssertionError— الملف والسطر، جاهزان للانتقال إليهما في محررك، ونوع الاستثناء.short test summary info— سطر واحد لكل فشل، بالشكلFAILED path::test_name - first line of the error. عند وجود حالات فشل كثيرة، اقرأ هذه القائمة أولاً.- السطر الأخير — الأعداد والزمن.
لماذا القراءة بهذا الترتيب؟ لأن كل سطر يضيّق نطاق البحث. يخبرك الملخص أي الاختبارات؛ وسطر > أي ادعاء؛ وسطر E أي جانب هو المختل؛ وسطر where أي استدعاء أنتجه. عندها فقط تفتح الكود — وتفتحه عند الدالة الصحيحة.
أحياناً يكون أول سطر E بالشكل AssertionError: assert ... وأحياناً assert ... فقط. إنه الفشل نفسه؛ تبقى البادئة عندما تحتوي القيم على علامات تنصيص.
المقارنات التي ستكتبها أكثر من غيرها
يمكن أن يلي assert أي تعبير، ويشرح pytest التعبيرات الشائعة منها. الملف users.py:
def find_user(users, email):
for user in users:
if user["email"] == email:
return user
return None
def active_emails(users):
return [u["email"] for u in users if u["active"]]أربعة اختبارات، وأربعة أنواع من المقارنة:
from users import active_emails, find_user
ADA = {"email": "ada@example.com", "active": True}
ALAN = {"email": "alan@example.com", "active": False}
def test_equal():
assert active_emails([ADA, ALAN]) == ["ada@example.com", "alan@example.com"]
def test_in():
assert "alan@example.com" in active_emails([ADA, ALAN])
def test_is_not_none():
assert find_user([ADA], "ADA@example.com") is not None
def test_greater():
assert len(active_emails([ADA, ALAN])) > 1الأربعة كلها تفشل. سطرا > و E لكل منها، مقتطعة من تشغيل واحد لـ pytest -q:
> assert active_emails([ADA, ALAN]) == ["ada@example.com", "alan@example.com"]
E AssertionError: assert ['ada@example.com'] == ['ada@example...@example.com']
E
E Right contains one more item: 'alan@example.com'
E Use -v to get more diff
> assert "alan@example.com" in active_emails([ADA, ALAN])
E AssertionError: assert 'alan@example.com' in ['ada@example.com']
E + where ['ada@example.com'] = active_emails([{'email': 'ada@example.com', 'active': True}, {'email': 'alan@example.com', 'active': False}])
> assert find_user([ADA], "ADA@example.com") is not None
E AssertionError: assert None is not None
E + where None = find_user([{'email': 'ada@example.com', 'active': True}], 'ADA@example.com')
> assert len(active_emails([ADA, ALAN])) > 1
E AssertionError: assert 1 > 1
E + where 1 = len(['ada@example.com'])
E + where ['ada@example.com'] = active_emails([{'email': 'ada@example.com', 'active': True}, {'email': 'alan@example.com', 'active': False}])كل منها يقول شيئاً مختلفاً:
==تقارن القيم. في حالة القوائم، يخبرك pytest أيضاً كيف تختلف — فالجانب الأيمن يحتوي على عنصر إضافي واحد. (active_emailsمحقة في استبعاد Alan؛ والتوقع في هذا الاختبار هو الخاطئ.)inتتحقق من العضوية، وتعرض الحاوية بأكملها لترى ما كان موجوداً بدلاً من ذلك.is None/is not Noneتتحقق من التطابق مع الكائن الوحيدNone. أعادتfind_userالقيمةNoneلأن البحث حساس لحالة الأحرف.<و>و<=و>=تعمل كما هي مكتوبة. سطراwhere: القيمة1أتت منlen(...)، وتلك القائمة أتت منactive_emails(...).
لماذا يهم الاختيار، إذا كان بالإمكان جعل كل صيغة تفشل؟ لأن التقرير يُبنى من التعبير الذي كتبته. الطريقة الخاطئة لسؤال "هل Alan في القائمة؟":
def test_count():
emails = ["ada@example.com"]
assert emails.count("alan@example.com") > 0> assert emails.count("alan@example.com") > 0
E AssertionError: assert 0 > 0
E + where 0 = <built-in method count of list object at 0x7d9189cec640>('alan@example.com')
E + where <built-in method count of list object at 0x7d9189cec640> = ['ada@example.com'].countالعنوان الرئيسي هو assert 0 > 0، والقائمة التي تهمك مدفونة في نهاية سطر where الثاني. أما الطريقة الصحيحة، assert "alan@example.com" in emails، فتضع القائمة في العنوان الرئيسي. اكتب assert بالصيغة نفسها للجملة التي في ذهنك، وسيشرح pytest الفشل بالمصطلحات نفسها.
القيمة المنطقية الضمنية: assert x ليست assert x == True
تنجح assert x كلما كانت x صحيحة ضمنياً (truthy) — أي شيء باستثناء False و None و 0 و "" والحاويات الفارغة. غالباً ما يكون هذا بالضبط ما تريده؛ وأحياناً يخفي خطأً برمجياً.
يحتوي rules.py على دالتين. overdue صحيحة؛ أما is_adult ففيها خطأ — إذ تعيد نصوصاً بدلاً من قيم منطقية:
def overdue(invoices):
return [i for i in invoices if i["days"] > 30]
def is_adult(age):
if age >= 18:
return "yes"
return "no"from rules import is_adult, overdue
def test_finds_overdue():
assert overdue([{"id": 1, "days": 12}, {"id": 2, "days": 30}])
def test_child_is_not_adult():
assert not is_adult(12)
def test_adult_truthy():
assert is_adult(40)
def test_adult_is_true():
assert is_adult(40) is Trueالأمر pytest -q، مع اختصار حالات الفشل الثلاث إلى أسطر E الخاصة بها:
FF.F [100%]
E AssertionError: assert []
E + where [] = overdue([{'id': 1, 'days': 12}, {'id': 2, 'days': 30}])
E AssertionError: assert not 'no'
E + where 'no' = is_adult(12)
E AssertionError: assert 'yes' is True
E + where 'yes' = is_adult(40)
=========================== short test summary info ============================
FAILED test_rules.py::test_finds_overdue - AssertionError: assert []
FAILED test_rules.py::test_child_is_not_adult - AssertionError: assert not 'no'
FAILED test_rules.py::test_adult_is_true - AssertionError: assert 'yes' is True
3 failed, 1 passed in 0.01sالفشل الأول: حتى assert some_list المجردة تحصل على تقرير مفيد. تقول assert [] إن القائمة عادت فارغة، لا خاطئة. ثلاثون يوماً ليست أكثر من ثلاثين، لذا فالكود صحيح وبيانات الاختبار هي الخاطئة — وقد عرفنا ذلك من سطر واحد.
والآن النقطة في FF.F. الاختبار test_adult_truthy نجح. تعيد is_adult النص "yes"، وهو صحيح ضمنياً، لذا تحققت assert is_adult(40) بدالة معيبة. والنص "no" صحيح ضمنياً أيضاً، ولهذا اكتشفته assert not is_adult(12) — ولكن بمحض الصدفة بحسب الحالة التي صادف أنك اختبرتها.
إذن، هل نستخدم assert x == True؟ إنها تكتشف "yes"، لكن لها ثغرتها الخاصة: في بايثون 1 == True و 0 == False. الملف stock.py:
def in_stock(qty):
return qty and qty > 0from stock import in_stock
def test_zero_equals_false():
assert in_stock(0) == False
def test_zero_is_false():
assert in_stock(0) is False.F [100%]
> assert in_stock(0) is False
E assert 0 is False
E + where 0 = in_stock(0)
1 failed, 1 passed in 0.01sيعيد التعبير qty and qty > 0 القيمة 0 عندما تكون qty مساوية لـ 0، وليس False. نجح اختبار ==؛ ولاحظ اختبار is المشكلة. والقاعدة المستخلصة:
assert x/assert not xعندما تكون أي قيمة صحيحة ضمنياً أو خاطئة ضمنياً مقبولة — "القائمة ليست فارغة"، "لا توجد رسالة خطأ".assert x is True/assert x is Falseعندما تعد الدالة بإعادةboolحقيقية.assert x == Trueنادراً جداً. فهي أكثر تساهلاً منis Trueوتقول أقل مما تقولهassert xالمجردة؛ وأدوات الفحص (linters) تنبّه إليها.
عندما تكون القيم كبيرة: الفروقات
في القيم القصيرة، تروي assert 80.0 == 70.0 القصة كاملة. أما في قائمة من أربعين عنصراً أو رسالة بريد من ثلاثة أسطر، فإن pytest يقارن الجانبين ويعرض ما يختلف فقط.
def test_list():
got = ["pen", "notebook", "bag", "eraser"]
assert got == ["pen", "notebook", "ruler", "eraser"]
def test_dict():
got = {"id": 7, "name": "Ada", "role": "admin", "active": True}
assert got == {"id": 7, "name": "Ada", "role": "editor", "active": True}
def test_multiline_string():
got = "Dear Ada,\nYour order 7 has shipped.\nThanks!"
assert got == "Dear Ada,\nYour order 7 has been shipped.\nThanks!"أسطر E لحالات الفشل الثلاث:
E AssertionError: assert ['pen', 'note...ag', 'eraser'] == ['pen', 'note...er', 'eraser']
E
E At index 2 diff: 'bag' != 'ruler'
E Use -v to get more diff
E AssertionError: assert {'id': 7, 'na...active': True} == {'id': 7, 'na...active': True}
E
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E Use -v to get more diff
E AssertionError: assert 'Dear Ada,\nY...ped.\nThanks!' == 'Dear Ada,\nY...ped.\nThanks!'
E
E Dear Ada,
E - Your order 7 has been shipped.
E ? -----
E + Your order 7 has shipped.
E Thanks!يُختصر أول سطر E باستخدام ... ليتسع؛ وتحمل الأسطر التي تحته التفاصيل:
- القوائم — أول فهرس تختلفان عنده، أو أي جانب يحتوي على عناصر إضافية.
- القواميس — المفاتيح التي تختلف قيمها. المفاتيح المتطابقة تُعدّ ولا تُطبع.
- النصوص — فرق سطراً بسطر. الأسطر التي تبدأ بمسافتين متطابقة في الجانبين. تشير
+إلى الجانب الأيسر من==، وتشير-إلى الجانب الأيمن. ويشير سطر?الذي تحتهما إلى الأحرف بدقة.
لذلك إذا كتبت دائماً assert actual == expected، فإن + هو ما أنتجه كودك و - هو ما أردته. اخلط الترتيب بين الاختبارات وستضطر إلى التوقف لتحديد أيهما أي منهما في كل مرة؛ حافظ عليه، ولن تضطر إلى ذلك أبداً.
النص الطويل المكوّن من سطر واحد يُعامل بالطريقة نفسها، مع تخطي الجزء المتطابق:
def test_receipt_line():
got = "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2420.00"
assert got == "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2402.00"E AssertionError: assert '2026-10-07 |...total 2420.00' == '2026-10-07 |...total 2402.00'
E
E Skipping 66 identical leading characters in diff, use -v to show
E - | total 2402.00
E ? -
E + | total 2420.00
E ? +رقمان متبادلان في نهاية سطر من 80 حرفاً، عُثر عليهما نيابة عنك.
-v و -vv: طلب الفرق الكامل
لاحظ التلميحات: Use -v to get more diff، و use -vv to show. افتراضياً يختصر pytest الشروحات الطويلة، وكل -v ترفع مستوى التفصيل. اختبار القاموس مع pytest -vv:
E AssertionError: assert {'id': 7, 'name': 'Ada', 'role': 'admin', 'active': True} == {'id': 7, 'name': 'Ada', 'role': 'editor', 'active': True}
E
E Common items:
E {'active': True, 'id': 7, 'name': 'Ada'}
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E
E Full diff:
E {
E 'id': 7,
E 'name': 'Ada',
E - 'role': 'editor',
E ? ^ ^^^
E + 'role': 'admin',
E ? ^ + ^
E 'active': True,
E }لا شيء مختصر: القاموسان كاملين، والعناصر المشتركة، وفرق بمفتاح واحد في كل سطر. لماذا لا نستخدم -vv دائماً؟ لأن الفرق الكامل في قائمة من مئتي سجل يمتد على مئتي سطر، بينما الملخص الافتراضي — "عند الفهرس 2" — هو ما كنت تحتاجه. ابدأ بالوضع الافتراضي؛ وأضف -vv للاختبار الوحيد الذي لا يكفي ملخصه.
رسالتك الخاصة: assert cond, "msg"
بعد فاصلة، يمكن أن تأخذ assert رسالة، يطبعها pytest فوق شرحه الخاص:
def test_everything_in_stock():
stock = {"pen": 12, "bag": 0}
for name, qty in stock.items():
assert qty > 0, f"{name} is out of stock"> assert qty > 0, f"{name} is out of stock"
E AssertionError: bag is out of stock
E assert 0 > 0بدون الرسالة كنت سترى assert 0 > 0 وستضطر لمعرفة أي عنصر كان صفراً. تستحق الرسالة مكانها عندما لا تقول القيم وحدها أي حالة فشلت أو لماذا توجد القاعدة. والاستخدام الخاطئ هو تكرار ما يعرضه pytest بالفعل: فالنص "expected 70.0 but got 80.0" لا يضيف شيئاً إلى assert 80.0 == 70.0.
فخ الصف (tuple)
عندما تجعل الرسالة السطر طويلاً، يكون من المغري وضع العبارة كلها بين قوسين:
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong"). [100%]
=============================== warnings summary ===============================
test_trap.py:3
/home/you/shop/test_trap.py:3: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
assert (total == 70.0, "total is wrong")
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sالاختبار نجح، رغم أن total تساوي 80.0. الأقواس التي تحتوي على فاصلة تبني صفاً (tuple) من عنصرين، والصف غير الفارغ صحيح ضمنياً دائماً — فعبارة assert تتحقق من الصف، لا من المقارنة. يطلق pytest تحذيراً، لكن من السهل تفويت تحذير في أسفل تشغيل طويل. أزل الأقواس:
def test_total():
total = 80.0
assert total == 70.0, "total is wrong"> assert total == 70.0, "total is wrong"
E AssertionError: total is wrong
E assert 80.0 == 70.0إذا كان السطر طويلاً جداً، فضع الأقواس حول الرسالة فقط:
def test_total():
total = 80.0
assert total == 70.0, (
"total is wrong after applying the loyalty discount"
)assert واحدة لكل سلوك
الاختبار الذي يتحقق من الحقول واحداً تلو الآخر يتوقف عند أول حقل يفشل. يحتوي orders.py على خطأين — الاسم لا تُزال منه المسافات، والكمية تبقى نصاً:
def parse_order(line):
order_id, customer, qty, price = line.split(",")
return {"id": int(order_id), "customer": customer, "qty": qty, "price": float(price)}الطريقة الخاطئة، ثم الطريقة الصحيحة:
from orders import parse_order
def test_parse_order_fields():
order = parse_order("1042, Ada ,3,15.0")
assert order["id"] == 1042
assert order["customer"] == "Ada"
assert order["qty"] == 3
assert order["price"] == 15.0
def test_parse_order():
order = parse_order("1042, Ada ,3,15.0")
assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}> assert order["customer"] == "Ada"
E AssertionError: assert ' Ada ' == 'Ada'
E
E - Ada
E + Ada
E ? + +
> assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}
E AssertionError: assert {'id': 1042, ...'price': 15.0} == {'id': 1042, ...'price': 15.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'customer': ' Ada '} != {'customer': 'Ada'}
E {'qty': '3'} != {'qty': 3}
E Use -v to get more diffيبلّغ الاختبار الأول عن خطأ واحد. تصلحه، وتشغّل مرة أخرى، وعندها فقط تقابل الخطأ الثاني. أما الاختبار الثاني فيقارن النتيجة بأكملها ويبلّغ عن الخطأين معاً.
المبدأ التوجيهي هو سلوك واحد لكل اختبار، وليس assert واحدة لكل اختبار. "تحليل سطر ينتج هذا السجل" سلوك واحد، لذا فإن مقارنة واحدة للقيمة بأكملها هي الخيار المثالي. ولا بأس بعدة عبارات assert عندما تصف معاً سلوكاً واحداً. ما يجب تجنبه هو اختبار واحد يتحقق من التحليل، ثم من تخطي الأسطر الفارغة، ثم من حساب الإجمالي — ثلاثة سلوكيات، حيث يخفي الفشل الأول الاثنين الآخرين، ولا يستطيع اسم الاختبار أن يقول ما الذي تعطل.
مقدار التقرير: --tb و -l
الجزء الواقع بين العنوان و file:line هو تتبع الاستدعاءات (traceback)، ويحدد --tb مقدار ما يُعرض منه. اختبار cart.py مع pytest -q --tb=short:
___________________________ test_total_with_discount ___________________________
test_cart.py:10: in test_total_with_discount
assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)السطر الفاشل فقط، لا الدالة بأكملها. ويعطي --tb=line موقعاً واحداً لكل فشل:
E assert 80.0 == 70.0
+ where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
/home/you/shop/test_cart.py:10: assert 80.0 == 70.0ويترك --tb=no الملخص فقط:
.F [100%]
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
1 failed, 1 passed in 0.01sالوضع الافتراضي، --tb=auto، هو الصيغة الطويلة التي رأيتها طوال الفصل. استخدم --tb=no أو --tb=line لترى ما الذي فشل عبر تشغيل كبير؛ ثم أعد تشغيل اختبار واحد بالوضع الافتراضي لترى السبب.
وفي الاتجاه المعاكس، يضيف -l (--showlocals) المتغيرات المحلية للدالة الفاشلة. الأمر pytest -q -l:
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
items = [(15.0, 2), (60.0, 1)]
test_cart.py:10: AssertionErrorفي اختبار يحتوي على حلقة تكرارية أو عدة متغيرات وسيطة، غالباً ما تكون تلك الكتلة الإضافية هي التي تفسر الفشل — وسترى ذلك في الحل في نهاية الفصل.
مثال تطبيقي متكامل
يقرأ invoice.py أسطراً مثل "pen, 12, 15.0":
def parse_line(line):
name, qty, price = line.split(",")
return {"name": name.strip(), "qty": int(qty), "price": float(price)}
def summarise(lines):
items = [parse_line(line) for line in lines if line.strip()]
total = sum(item["qty"] * item["price"] for item in items)
return {
"count": len(items),
"names": [item["name"] for item in items],
"total": round(total, 2),
}
def find_item(items, name):
for item in items:
if item["name"] == name:
return item
return Noneالملف test_invoice.py هو الخطة من قسم "قبل أن تكتب الاختبار"، صف واحد لكل اختبار، وكل منها مكتوب بالمقارنة الواردة في العمود الأخير من الجدول:
from invoice import find_item, parse_line, summarise
LINES = ["pen, 12, 15.0", "", "bag, 2, 850.0"]
def test_parse_line_strips_and_converts():
assert parse_line(" pen , 12, 15.0") == {"name": "pen", "qty": 12, "price": 15.0}
def test_blank_lines_are_skipped():
assert summarise(LINES)["count"] == 2
def test_summary():
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
def test_find_item_present():
items = [parse_line("pen, 12, 15.0")]
assert find_item(items, "pen") is not None
def test_find_item_missing():
assert find_item([], "pen") is None
def test_total_is_positive():
total = summarise(LINES)["total"]
assert total > 0, f"total should be positive, got {total}"الأمر pytest -q:
...... [100%]
6 passed in 0.01sالآن يأتي شخص ما و"يرتّب" summarise بحيث تعود الأسماء مرتبة، فيغيّر سطراً واحداً إلى "names": sorted(item["name"] for item in items),. الأمر pytest -q --tb=short:
..F... [100%]
=================================== FAILURES ===================================
_________________________________ test_summary _________________________________
test_invoice.py:15: in test_summary
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
E AssertionError: assert {'count': 2, ...otal': 1880.0} == {'count': 2, ...otal': 1880.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'names': ['bag', 'pen']} != {'names': ['pen', 'bag']}
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_invoice.py::test_summary - AssertionError: assert {'count': 2, .....
1 failed, 5 passed in 0.01sاقرأه بالطريقة التي علّمها هذا الفصل. فشل اختبار واحد من ستة، test_summary، عند السطر 15. أول سطر E مختصر، لذا فالتفاصيل في الأسفل: مفتاحان متطابقان، والمفتاح names مختلف — ['bag', 'pen'] من الكود (اليسار) مقابل ['pen', 'bag'] في الاختبار (اليمين). تغيّر الترتيب، ولا شيء غيره. وما إذا كان ذلك خطأً أم تغييراً مقصوداً أصبح الآن قراراً يخص الفواتير، لا بحثاً مضنياً في الكود.
لاحظ أيضاً أي الاختبارات لم تفشل. العدد، وعمليات البحث، والإجمالي ما زالت تنجح، لذا يضيّق التقرير نطاق التغيير إلى مفتاح واحد في قاموس واحد. هذه هي ثمرة مبدأ سلوك واحد لكل اختبار: نمط النجاحات والإخفاقات هو في حد ذاته تشخيص.
عندما يتعطل شيء ما
E AssertionError بدون قيم، من وحدة مساعدة تغطي إعادة الكتابة ملفات الاختبار و conftest.py فقط. عبارة assert داخل ملف مثل checks.py تعطي AssertionError مجردة. سجّل الوحدة قبل استيرادها، في أعلى conftest.py: pytest.register_assert_rewrite("checks"). عندها يعرض التقرير assert 0 > 0 كالمعتاد.
PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses? لقد كتبت assert (condition, "message"). هذا صف (tuple)، ولا يمكن للاختبار أن يفشل أبداً. أزل الأقواس الخارجية؛ وضع الرسالة وحدها بين قوسين إذا كان السطر طويلاً.
AssertionError: assert None == ... أو + where None = ... الدالة التي استدعيتها أعادت None. عادةً يكون فيها مسار بلا return، أو أنها تعدّل شيئاً في مكانه ولا تعيد شيئاً — و list.sort() هي المثال الكلاسيكي.
Use -v to get more diff / ...Full output truncated (8 lines hidden), use '-vv' to show ليس خطأً — فقد اختصر pytest الشرح. أعد تشغيل ذلك الاختبار وحده مع -vv.
اختبار ينجح بينما القيمة خاطئة بوضوح ابحث عن assert x حيث x نص غير فارغ مثل "no"، أو assert x == True حيث x تساوي 1، أو فخ الصف. الاختبار الذي لم يفشل قط لم يثبت قط أنه يعمل — اكسر الكود عمداً مرة واحدة وشاهده يتحول إلى الأحمر.
Step 4 of 6 — Predict
Check your understanding
يفشل الاختبار. ما سطر الشرح الذي يطبعه pytest تحت أول سطر E؟
def test_names():
got = ["pen", "bag"]
assert got == ["pen", "bag", "ink"]- AAt index 2 diff: 'ink' != None
- BLeft contains one more item: 'ink'
- CRight contains one more item: 'ink'
- Dassert 2 == 3
قيمة total هي 80.0، ومع ذلك ينجح هذا الاختبار. لماذا؟
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong")- Aيتجاهل pytest جمل assert التي تحمل رسالة
- B`80.0 == 70.0` صحيحة في الأعداد العشرية
- Cنص الرسالة يحل محل الشرط
- Dالأقواس تبني tuple من عنصرين، والـ tuple غير الفارغ صحيح دائماً
أنت تكتب دائماً assert actual == expected. في فرق النصوص، ماذا تعني الأسطر التي تبدأ بـ + والتي تبدأ بـ -؟
- A`+` هي القيمة المتوقعة، و `-` ما أنتجه الكود
- B`+` ما أنتجه الكود (الطرف الأيسر)، و `-` القيمة المتوقعة (الطرف الأيمن)
- C`+` تعلّم الأحرف المضافة في القيمتين، و `-` المحذوفة فيهما
- Dيعتمد ذلك على أي القيمتين أطول
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.
دورك الآن
أنشئ grades.py يحتوي على أربع دوال:
letter(score)—"A"من 80، و"B"من 65، و"C"من 50، وإلا"F"passed(score)— قيمةboolحقيقية،Trueمن 50report(scores)— تأخذ قاموساً من الأسماء إلى الدرجات، وتعيد قاموساً من الأسماء إلى التقديراتbest(scores)— الاسم صاحب أعلى درجة، أوNoneلقاموس فارغ
اكتب أولاً خطة الاختبار على شكل جدول، كما فعل هذا الفصل: الحالة، والمدخل، والمتوقع، والـ assert التي ستستخدمها. ثم اكتب test_grades.py بستة اختبارات على الأقل. استخدم كلاً مما يلي مرة واحدة على الأقل: == على قاموس بأكمله، و in، و is None، و is True أو is False، ومقارنة مثل >=، و assert برسالة تسمّي الحالة الفاشلة.
ثم اكسر الأشياء عمداً واقرأ كل تقرير:
- اجعل
passedتعيدscore >= 50 and scoreبدلاً من قيمة bool. أي الاختبارات تلاحظ ذلك؟ - انقل حد
"B"من 65 إلى 66. أي الاختبارات تلاحظ، وأيها لا تلاحظ — ولماذا؟ - ضع إحدى عبارات
assertلديك بين قوسين مع رسالتها. اعثر على التحذير. - مع إبقاء الكسر رقم 2، شغّل الملف مع
--tb=line، ثم شغّل الاختبار الفاشل مع-l.
دوّن، لكل كسر، أول سطر في التقرير أخبرك بما كان خاطئاً.
الحل
الخطة أولاً. الحدود هي المواضع التي يخطئ فيها كود التقدير، لذا يحصل كل حد على جانبيه — 80 و 79، و 65 و 64، و 50 و 49:
| الحالة | المدخل | المتوقع | الـ assert | | --- | --- | --- | --- | | كل حد، من الجانبين | 80, 79, 65, 64, 50, 49 | A, B, B, C, C, F | == مع رسالة تسمّي الدرجة | | passed قيمة bool حقيقية | 50, 49 | True, False | is True, is False | | التقرير بأكمله | أربعة طلاب | قاموس التقديرات بأكمله | == على القاموس | | هناك من رسب | الأربعة أنفسهم | "F" بين التقديرات | in | | best هو الأفضل فعلاً | الأربعة أنفسهم | لا درجة أعلى من درجة الفائز | >= مع رسالة | | لا أحد للترتيب | {} | None | is None |
الملف grades.py:
def letter(score):
if score >= 80:
return "A"
if score >= 65:
return "B"
if score >= 50:
return "C"
return "F"
def passed(score):
return score >= 50
def report(scores):
return {name: letter(score) for name, score in scores.items()}
def best(scores):
if not scores:
return None
return max(scores, key=scores.get)الملف test_grades.py:
from grades import best, letter, passed, report
SCORES = {"ada": 91, "alan": 64, "grace": 50, "linus": 49}
def test_letter_boundaries():
cases = [(80, "A"), (79, "B"), (65, "B"), (64, "C"), (50, "C"), (49, "F")]
for score, expected in cases:
assert letter(score) == expected, f"letter({score})"
def test_passed_returns_a_real_bool():
assert passed(50) is True
assert passed(49) is False
def test_report():
assert report(SCORES) == {"ada": "A", "alan": "C", "grace": "C", "linus": "F"}
def test_a_failing_letter_appears():
assert "F" in report(SCORES).values()
def test_best_has_the_highest_score():
top = best(SCORES)
for name, score in SCORES.items():
assert SCORES[top] >= score, f"{name} scored more than {top}"
def test_best_of_nobody():
assert best({}) is Noneالأمر pytest -q:
...... [100%]
6 passed in 0.01sالكسر 1 — passed تعيد score >= 50 and score:
.F.... [100%]
> assert passed(50) is True
E assert 50 is True
E + where 50 = passed(50)
FAILED test_grades.py::test_passed_returns_a_real_bool - assert 50 is True
1 failed, 5 passed in 0.01sالقيمة 50 صحيحة ضمنياً، لذا كانت assert passed(50) ستسمح بمرورها. لكن is True اكتشفتها، و assert 50 is True تسمّي القيمة التي كان يجب أن تكون bool.
الكسر 2 — نُقل حد "B" إلى 66، مع --tb=line:
F..... [100%]
=================================== FAILURES ===================================
E AssertionError: letter(65)
assert 'C' == 'B'
- B
+ C
/home/you/grades/test_grades.py:9: AssertionError: letter(65)
=========================== short test summary info ============================
FAILED test_grades.py::test_letter_boundaries - AssertionError: letter(65)
1 failed, 5 passed in 0.01sلم يلاحظ ذلك إلا اختبار الحدود. ما زال test_report ينجح لأنه لا يوجد طالب في SCORES حصل على 65 بالضبط — فالاختبار يغطي فقط القيم التي تضعها فيه، ولهذا أدرجت الخطة جانبي كل حد. تأتي الرسالة letter(65) أولاً، وهي ضرورية: فالحلقة تشغّل ست حالات على السطر نفسه، وبدونها كنت ستعرف أن تقديراً ما خاطئ، لكن ليس لأي درجة. + C هو ما أعاده الكود، و - B هو ما توقعه الاختبار.
الفشل نفسه مع pytest -q -l test_grades.py::test_letter_boundaries ينتهي بمتغيرات الحلقة:
cases = [(80, 'A'), (79, 'B'), (65, 'B'), (64, 'C'), (50, 'C'), (49, 'F')]
expected = 'B'
score = 65الكسر 3 — assert (best({}) is None, "no scores, no best") في السطر 32:
...... [100%]
/home/you/grades/test_grades.py:32: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
6 passed, 1 warning in 0.01sستة نجحت — وأحدها لم يعد قادراً على الفشل، أياً كان ما تعيده best. التحذير هو العلامة الوحيدة.
الأسطر الأولى التي أخبرتك بما كان خاطئاً: assert 50 is True، و AssertionError: letter(65)، و assertion is always true. أسطر كهذه هي ما ستبحث عنه أولاً في كل فشل من الآن فصاعداً.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz