اختبار الاستثناءات — pytest.raises
الأخطاء سلوك أيضاً، ولذلك تحتاج إلى اختبارات. pytest.raises و DID NOT RAISE ومطابقة الأصناف الفرعية، ولماذا match= تعبير نمطي، وفحص الخطأ الملتقط عبر excinfo، ولماذا لا يوضع داخل كتلة with إلا السطر الأخير.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك دالة صغيرة تحرس رصيداً. بُني فيها رفضان: مبلغ ليس موجباً، ومبلغ أكبر من الموجود.
wallet.py:
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amountسطرا raise هذان سلوك أيضاً، تماماً كما هو return. يعتمد من يستدعي الدالة على أن withdraw(100.0, -5.0) سترفض، وإن اختفى هذا الرفض يوماً ما تحرّك المال في الاتجاه الخطأ. لذلك يستحق اختباراً. وأول اختبار يكتبه معظم الناس يبدو هكذا:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passافترض الآن أن أحدهم حذف الفحص if amount <= 0 من withdraw. شغّل pytest -q:
. [100%]
1 passed in 0.01sالفحص اختفى والاختبار ما زال ينجح. حين لا يُرفع أي استثناء تنتهي كتلة try ببساطة، ويُتخطّى except، وتنتهي الدالة دون أي assert فاشل. كل ما تحقق منه الاختبار هو أنه إذا وقع ValueError فسيُتجاهل. ولم يقل قط إن الخطأ يجب أن يقع.
هذا الفصل عن قول ذلك على الوجه الصحيح — وعن الطرق القليلة التي ينجح بها اختبارٌ لخطأ في حين لا ينبغي له أن ينجح.
بنهاية هذا الفصل ستكون قادراً على
- اختبار أن الكود يرفع استثناءً باستخدام
pytest.raises، وقراءةDID NOT RAISE - شرح لماذا يكون
pytest.raises(Exception)واسعاً أكثر من اللازم في كل الحالات تقريباً - فحص الرسالة باستخدام
match=، مع إدراك أنها تعبير نمطي (regular expression) - فحص الاستثناء الملتقط عبر
excinfo - إبقاء سطر الرفع وحده داخل كتلة
with، وتفسير السبب - التعرّف على
ExceptionGroupوفحص ما بداخلها
المتطلبات المسبقة: اكتشاف الاختبارات وتشغيلها.
قبل أن تكتب الاختبار
جودة اختبار الخطأ من جودة القرار الذي يقف خلفه. قبل أن تكتب pytest.raises، احسم ثلاثة أمور على الورق.
1. ما الذي تَعِد به الدالة بالضبط؟ اقرأ withdraw كعقد، لا ككود:
- المدخل: رصيد ومبلغ. المخرج: الرصيد الجديد،
balance - amount. - الرفض الأول: المبلغ الصفري أو السالب يرفع
ValueError، وتذكر الرسالة المبلغ. - الرفض الثاني: المبلغ الأكبر من الرصيد يرفع
InsufficientFunds، الذي يحملbalanceوamountكخصائص (attributes). - الآثار الجانبية: لا شيء. لا تغيّر
withdrawأي شيء خارجها، لذا فعبارة "لم يتغير شيء بعد الرفض" صحيحة تلقائياً هنا — ولن تكون كذلك في مثال البنك في النهاية.
كل رفض وعد مستقل، له ثلاثة أجزاء يمكن فحصها: أي صنف، أي رسالة، وأي حالة بقيت بعده.
2. ما الذي يجب أن يكون جاهزاً؟ لا شيء أكثر مما في الفصل السابق. pytest.raises جزء من pytest، وre في المكتبة القياسية. تحتاج إلى بيئة افتراضية مفعّلة، وpytest مثبّتاً، وwallet.py قابلاً للاستيراد من ملف الاختبار — وهو في هذا الفصل في المجلد نفسه مع test_wallet.py. تحقق من الأمرين الأولين مرة واحدة:
$ python -m pytest --version
pytest 9.1.1ثم انظر إلى السلوك الحقيقي للكود قبل أن تكتب عنه ادعاءً واحداً. تجربة قصيرة تجيب عن أسئلة لا يجيب عنها التخمين — هل تُعاد 0.0 أم يُرفض الطلب؟ كيف تبدو الرسالة؟
from wallet import InsufficientFunds, withdraw
print(withdraw(100.0, 30.0))
print(withdraw(100.0, 100.0))
try:
withdraw(100.0, 100.01)
except InsufficientFunds as error:
print(repr(error))70.0
0.0
InsufficientFunds('cannot withdraw 100.01; balance is 100.00')3. أي الحالات؟ الأخطاء تعيش عند الحدود، فخطّط حولها — الحالة العادية، وطرفا كل رفض من الجهتين، والمدخل الخاطئ بوضوح:
| الحالة | المدخل | المتوقع | |---|---|---| | سحب عادي | withdraw(100.0, 30.0) | يعيد 70.0 | | الرصيد كاملاً (حدّ، مسموح) | withdraw(100.0, 100.0) | يعيد 0.0، بلا خطأ | | أكثر بقليل (حدّ، مرفوض) | withdraw(100.0, 100.01) | InsufficientFunds، amount == 100.01 | | صفر (حدّ، مرفوض) | withdraw(100.0, 0) | ValueError | | سالب | withdraw(100.0, -5.0) | ValueError، والرسالة تحتوي (got -5.0) | | سحب على المكشوف واضح | withdraw(100.0, 150.0) | InsufficientFunds، balance == 100.0، amount == 150.0 |
الصفّان "المسموحان" بأهمية حالات الرفض نفسها. فحصٌ مكتوب بـ >= بدلاً من > يرفض حالة سحب الرصيد كاملاً، ولا يلاحظ ذلك إلا اختبار يتوقع ألا يقع خطأ هناك.
ما الذي لا تختبره. سلوك بايثون نفسها: أن استدعاء withdraw(100.0) بوسيط ناقص يرفع TypeError هو وعد بايثون لا وعدك. والصياغة الكاملة لكل رسالة، ما لم تكن الصياغة نفسها هي العقد — يكفي جزء يحمل القيمة. وكل ما لم تَعِد به الدالة قط، مثل ما يحدث حين تمرّر النص "ten". اختبار هذه الأمور يضيف اختبارات تنكسر عند تغييرات بريئة ولا تحمي شيئاً.
بقية الفصل تحوّل هذا الجدول إلى اختبارات، أداةً بعد أداة.
pytest.raises — "يجب أن يرفع هذا استثناءً"
pytest.raises مدير سياق (context manager). يُتوقع من كل ما داخل كتلة with أن يرفع الاستثناء المسمّى:
import pytest
from wallet import withdraw
def test_negative_amount_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, -5.0)
def test_zero_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 0).. [100%]
2 passed in 0.01sإذا خرج ValueError من الكتلة التقطه pytest.raises وتابع الاختبار بعد الكتلة. هذا النصف كان موجوداً في try/except أصلاً. النصف الآخر هو ما يحدث حين لا يخرج شيء. احذف الفحص if amount <= 0 مرة أخرى وشغّل الملف نفسه:
FF [100%]
=================================== FAILURES ===================================
_______________________ test_negative_amount_is_rejected _______________________
def test_negative_amount_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:7: Failed
____________________________ test_zero_is_rejected _____________________________
def test_zero_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:12: Failed
=========================== short test summary info ============================
FAILED test_wallet.py::test_negative_amount_is_rejected - Failed: DID NOT RAI...
FAILED test_wallet.py::test_zero_is_rejected - Failed: DID NOT RAISE ValueError
2 failed in 0.01sDID NOT RAISE ValueError هي الجملة التي لم تكن نسخة try/except لتقولها أبداً. وهذا كل ما في pytest.raises: التقاط الخطأ المتوقع، والفشل بصوت عالٍ حين لا يصل.
أي الاستثناءات تُعدّ مطابقة
يطابق pytest.raises(X) تماماً كما يفعل except X:: الصنف X نفسه أو أي صنف فرعي منه. تشكّل استثناءات بايثون شجرة عائلة، ويمكنك رؤية فرع منها:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
Trueإذن KeyError هو LookupError. هذا مفيد، وهو الفخ أيضاً. إليك اختبارين ينجح كلاهما:
import pytest
from wallet import withdraw
def test_missing_price_is_a_lookup_error():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_too_broad():
with pytest.raises(Exception):
withdraw(100.0) # forgot the amount.. [100%]
2 passed in 0.01sالأول سليم — التقاط الصنف الأب عن قصد. والثاني اختبار لا يثبت شيئاً. لم يصل withdraw(100.0) إلى جسم الدالة أصلاً: رفعت بايثون TypeError بسبب الوسيط الناقص، ولأن كل استثناء عادي صنف فرعي من Exception قبله pytest.raises(Exception) بسرور. خطأ إملائي، أو استيراد خاطئ، أو AttributeError — أيٌّ منها كان سيجعل هذا الاختبار أخضر.
القاعدة: سمِّ أدق استثناء يَعِد الكود برفعه. كلما اتسع الصنف، زادت الإخفاقات غير ذات الصلة التي يخفيها.
أما الاتجاه الآخر فصارم. الاستثناء الذي ليس هو الصنف المسمّى ولا صنفاً فرعياً منه لا يُلتقط؛ بل يفلت من الكتلة ويُفشل الاختبار كخطأ عادي. (يختصر pytest -q --tb=short كل تتبّع (traceback) إلى سطر واحد لكل إطار.)
import pytest
from wallet import withdraw
def test_overdraft_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 150.0)F [100%]
=================================== FAILURES ===================================
__________________________ test_overdraft_is_rejected __________________________
test_wallet.py:8: in test_overdraft_is_rejected
withdraw(100.0, 150.0)
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_overdraft_is_rejected - wallet.InsufficientFunds:...
1 failed in 0.01sليس DID NOT RAISE — فقد رُفع شيء بالفعل، لكنه ليس ما سمّيته، ويُريك pytest من أين جاء.
match= — فحص الرسالة
يخبرك النوع أي نوع من الرفض كان. وتخبرك الرسالة لماذا، وغالباً ما تحمل القيمة المخالفة. يفحصها match=:
import pytest
from wallet import withdraw
def test_message_says_positive():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100.0, -5.0)
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0).F [100%]
=================================== FAILURES ===================================
______________________________ test_whole_message ______________________________
def test_whole_message():
> with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
E AssertionError: Regex pattern did not match.
E Expected regex: 'amount must be positive (got -5.0)'
E Actual message: 'amount must be positive (got -5.0)'
E Did you mean to `re.escape()` the regex?
test_wallet.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_message - AssertionError: Regex pattern did...
1 failed, 1 passed in 0.01sالنص المتوقع والرسالة الفعلية متطابقان، ومع ذلك فشل الاختبار. السبب في السطر الأول: match= ليس نصاً عادياً. إنه تعبير نمطي، وينفّذ pytest re.search(pattern, str(exception)). ولهذا نتيجتان:
- يبحث
re.searchفي أي موضع من الرسالة. طابق"must be positive"في المنتصف. لتثبيت البداية أو النهاية استخدم^و$. - لبعض المحارف معنى خاص. في التعبير النمطي يشكّل
( )مجموعة ولا يطابقان أي قوس؛ ويطابق.أي محرف؛ ولكلٍّ من+و*و?و[و$وظيفته أيضاً.
حالة . هي الأهدأ، لأنها تخطئ في اتجاه النجاح:
import re
print(re.search("100.00", "balance is 100.00"))
print(re.search("100.00", "balance is 100500"))
print(re.search(re.escape("100.00"), "balance is 100500"))<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
Noneطابق "100.00" النص 100500 دون أي اعتراض. تحوّل re.escape كل محرف خاص إلى محرف حرفي، لذا حين تعني "هذا النص بالضبط" مرّر النص عبرها — تماماً كما اقترح تلميح pytest:
import re
import pytest
from wallet import withdraw
def test_whole_message():
message = "amount must be positive (got -5.0)"
with pytest.raises(ValueError, match=re.escape(message)):
withdraw(100.0, -5.0). [100%]
1 passed in 0.01sمقدار ما تطابقه من الرسالة مسألة تقدير. جزء قصير مميز — القيمة، الكلمة المفتاحية — يصمد أمام إعادة الصياغة البريئة؛ أما الجملة الكاملة فتنكسر في اللحظة التي يصحّح فيها أحدهم خطأً إملائياً.
excinfo — الاستثناء الذي التقطته
تمنحك with pytest.raises(...) as excinfo مقبضاً لما التُقط. ألقِ نظرة داخله مرة واحدة باستخدام pytest -q -s (الخيار -s يسمح بظهور print):
import pytest
from wallet import InsufficientFunds, withdraw
def test_look_inside():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
print()
print("type :", excinfo.type)
print("value :", repr(excinfo.value))
print("str :", str(excinfo.value))
print("fields:", excinfo.value.balance, excinfo.value.amount)type : <class 'wallet.InsufficientFunds'>
value : InsufficientFunds('cannot withdraw 150.00; balance is 100.00')
str : cannot withdraw 150.00; balance is 100.00
fields: 100.0 150.0
.
1 passed in 0.01s-
excinfo.typeهو الصنف الذي رُفع فعلاً — مفيد حين تلتقط صنفاً أباً وتريد معرفة أي ابن وصل. -
excinfo.valueهو كائن الاستثناء نفسه، بكل خصائصه. -
str(excinfo.value)هو الرسالة — النص نفسه الذي يبحث فيهmatch=.
كانت أسطر الطباعة للنظر فقط. في الاختبار الحقيقي تستخدم assert:
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_reports_both_numbers():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.balance == 100.0
assert excinfo.value.amount == 150.0
assert str(excinfo.value) == "cannot withdraw 150.00; balance is 100.00". [100%]
1 passed in 0.01sحين يحمل الاستثناء بيانات في خصائصه، يكون التحقق من الخصائص أمتن من تحليل الرسالة. لاحظ أن أسطر assert تقع بعد كتلة with لا داخلها. هذا ليس أسلوباً؛ والقسم التالي يشرح السبب.
سطر الرفع وحده داخل الكتلة
في اللحظة التي يُرفع فيها استثناء تغادر بايثون الكتلة. يُتخطّى كل سطر بعد سطر الرفع — بما في ذلك أسطر assert:
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 999.0 # never runs
def test_text_amount_is_rejected():
with pytest.raises(ValueError):
amount = float("ten") # raises ValueError itself
withdraw(100.0, amount).. [100%]
2 passed in 0.01sالاختباران أخضران، وكلاهما خاطئ. في الأول، assert balance == 999.0 ادعاء كاذب لم يُنفَّذ قط. وفي الثاني رفع float("ten") الاستثناء ValueError في سطر التهيئة، فلم تُستدعَ withdraw أصلاً — ينجح الاختبار سواء فحصت withdraw شيئاً أم لا.
القاعدة: التهيئة قبل الكتلة، واستدعاء رافع واحد داخلها، والتحقق بعدها.
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 100.0. [100%]
1 passed in 0.01sالآن يُنفَّذ الـ assert، ويتحقق من شيء حقيقي: السحب المرفوض ترك balance كما هو، لأن الإسناد لم يحدث قط.
أكثر من استثناء مقبول
يقبل pytest.raises صفّاً (tuple) أيضاً، تماماً مثل except مرة أخرى. أي صنف من المذكورة يفي بالغرض، ويخبرك excinfo.type بأيها وصل:
import pytest
from wallet import InsufficientFunds, withdraw
def test_bad_withdrawal_is_refused():
with pytest.raises((ValueError, InsufficientFunds)) as excinfo:
withdraw(100.0, 150.0)
print()
print(excinfo.type.__name__)InsufficientFunds
.
1 passed in 0.01sاستخدم هذا حين يكون العقد فعلاً "واحداً من هذه" — كأن يكون الكود قادراً على رفع أيٍّ منها بشكل مشروع حسب المنصة. أما لدوالك أنت، فصنف واحد دقيق هو الوعد الأفضل في كل الحالات تقريباً.
اختبار أن شيئاً لا يرفع استثناءً
لا توجد دالة pytest.does_not_raise() تلجأ إليها، ولا تحتاج إليها. استدعِ الكود. إن رفع استثناءً فشل الاختبار بذلك الاستثناء وتتبّعه:
from wallet import withdraw
def test_whole_balance_can_be_withdrawn():
assert withdraw(100.0, 100.0) == 0.0. [100%]
1 passed in 0.01sغيّر amount > balance إلى amount >= balance في withdraw — خطأ "الفارق بواحد" الذي يكتبه كثيرون — فيُبلغ pytest -q --tb=short:
F [100%]
=================================== FAILURES ===================================
_____________________ test_whole_balance_can_be_withdrawn ______________________
test_wallet.py:5: in test_whole_balance_can_be_withdrawn
assert withdraw(100.0, 100.0) == 0.0
^^^^^^^^^^^^^^^^^^^^^^
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 100.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_balance_can_be_withdrawn - wallet.Insuffici...
1 failed in 0.01sالاستثناء غير المتوقع إخفاق بحد ذاته. تغليف الاستدعاء بـ try/except: pytest.fail() لا يفعل سوى رمي ذلك التتبّع.
عدة أخطاء معاً: ExceptionGroup
منذ بايثون 3.11 يستطيع الكود رفع عدة استثناءات معاً في ExceptionGroup. أضف إلى wallet.py نسخة دفعية تحاول كل عملية سحب وتُبلغ عن كل الإخفاقات معاً:
def withdraw_all(balance: float, amounts: list[float]) -> float:
errors = []
for amount in amounts:
try:
balance = withdraw(balance, amount)
except (ValueError, InsufficientFunds) as error:
errors.append(error)
if errors:
raise ExceptionGroup(f"{len(errors)} withdrawals failed", errors)
return balanceالمجموعة صنف استثناء قائم بذاته. لا ينظر pytest.raises(ValueError) داخلها. لفحص محتواها التقط المجموعة واسأل excinfo.group_contains، أو صِف المجموعة كاملة باستخدام pytest.RaisesGroup:
import pytest
from wallet import InsufficientFunds, withdraw_all
def test_plain_raises_does_not_look_inside():
with pytest.raises(ValueError):
withdraw_all(100.0, [-5.0, 30.0, 500.0])
def test_group_contains():
with pytest.raises(ExceptionGroup) as excinfo:
withdraw_all(100.0, [-5.0, 30.0, 500.0])
assert excinfo.group_contains(ValueError, match="must be positive")
assert excinfo.group_contains(InsufficientFunds)
assert not excinfo.group_contains(KeyError)
def test_raises_group():
with pytest.RaisesGroup(ValueError, InsufficientFunds):
withdraw_all(100.0, [-5.0, 30.0, 500.0])شغّل بـ pytest -q --tb=no لترى النتيجة فقط:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sيفشل الاختبار الأول: أفلتت المجموعة من pytest.raises(ValueError) كما هي. يجيب group_contains عن سؤال "هل يوجد واحد من هذه في أي مكان بالداخل؟" ويقبل match= أيضاً. أما pytest.RaisesGroup فأشد صرامة: يجب أن تحتوي المجموعة على الاستثناءات المذكورة بالضبط — لا أكثر ولا أقل، بأي ترتيب. وصل كلاهما في pytest 8 وهما موجودان في pytest 9. لن تحتاج إليهما كثيراً؛ ستحتاج إليهما يوم تسلّمك مكتبةٌ ما مجموعة.
مثال تطبيقي متكامل
بنك صغير فيه ثلاث حالات رفض: حساب غير معروف، ومبلغ غير موجب، وسحب على المكشوف. يعمل فحص المبلغ تماماً مثل فحص withdraw، لذا تركّز الاختبارات أدناه جهدها على الحالتين الأخريين. يرث AccountNotFound عمداً من LookupError، كي يتمكن المستدعون من التقاطه كما يلتقطون مفتاحاً مفقوداً في قاموس.
bank.py:
class AccountNotFound(LookupError):
def __init__(self, account_id: str):
self.account_id = account_id
super().__init__(f"no account with id {account_id!r}")
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
class Bank:
def __init__(self) -> None:
self._balances: dict[str, float] = {}
def open(self, account_id: str, deposit: float) -> None:
self._balances[account_id] = deposit
def balance(self, account_id: str) -> float:
try:
return self._balances[account_id]
except KeyError:
raise AccountNotFound(account_id) from None
def transfer(self, source: str, target: str, amount: float) -> None:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
# Look both accounts up first, so nothing moves if either is missing.
available = self.balance(source)
self.balance(target)
if amount > available:
raise InsufficientFunds(available, amount)
self._balances[source] -= amount
self._balances[target] += amounttest_bank.py:
import pytest
from bank import AccountNotFound, Bank, InsufficientFunds
def make_bank() -> Bank:
bank = Bank()
bank.open("alice", 100.0)
bank.open("bob", 20.0)
return bank
def test_transfer_moves_money():
bank = make_bank()
bank.transfer("alice", "bob", 30.0) # must not raise
assert bank.balance("alice") == 70.0
assert bank.balance("bob") == 50.0
def test_unknown_account_is_a_lookup_error():
bank = make_bank()
with pytest.raises(LookupError) as excinfo:
bank.balance("carol")
assert excinfo.type is AccountNotFound
assert excinfo.value.account_id == "carol"
def test_unknown_target_moves_nothing():
bank = make_bank()
with pytest.raises(AccountNotFound, match="'carol'"):
bank.transfer("alice", "carol", 30.0)
assert bank.balance("alice") == 100.0
def test_overdraft_reports_both_numbers():
bank = make_bank()
with pytest.raises(InsufficientFunds) as excinfo:
bank.transfer("bob", "alice", 25.0)
assert excinfo.value.balance == 20.0
assert excinfo.value.amount == 25.0
assert bank.balance("bob") == 20.0pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/bank
collecting ... collected 4 items
test_bank.py::test_transfer_moves_money PASSED [ 25%]
test_bank.py::test_unknown_account_is_a_lookup_error PASSED [ 50%]
test_bank.py::test_unknown_target_moves_nothing PASSED [ 75%]
test_bank.py::test_overdraft_reports_both_numbers PASSED [100%]
============================== 4 passed in 0.01s ===============================ثلاثة أمور تستحق الملاحظة.
أولاً، يُبقي كل اختبار كتلة with على استدعاء واحد، ويتحقق من الحالة بعدها. الاختبار test_unknown_target_moves_nothing هو الأثمن: لو أخذت transfer المال من Alice قبل أن تكتشف أن Carol غير موجودة، لظلّت ترفع AccountNotFound — ولالتقط الـ assert الذي بعد الكتلة التحويلَ نصف المكتمل مع assert 70.0 == 100.0.
ثانياً، يلتقط test_unknown_account_is_a_lookup_error الصنف الأب عن قصد، لأن هذا هو الوعد الذي يقدمه bank.py، ثم يستخدم excinfo.type لتحديد أي ابن وصل.
ثالثاً، يتحقق match="'carol'" من أن الرسالة تذكر الحساب المطلوب — وهي التفصيلة التي يحتاجها قارئ الخطأ أكثر من غيرها.
الأخطاء الشائعة وحلولها
Failed: DID NOT RAISE ValueError انتهت الكتلة دون رفع أي شيء. إما أن الكود فقد فحصه فعلاً (وقد أدّى الاختبار مهمته)، وإما أن الاستدعاء داخل الكتلة ليس ما تظنه — وسائط خاطئة تصادف أنها صالحة، أو دالة تعيد قيمة خطأ بدلاً من رفع استثناء.
AssertionError: Regex pattern did not match. يليها Did you mean to re.escape() the regex? الرسالة صحيحة لكنها تحتوي محارف التعبير النمطي، عادةً ( أو ) أو . أو [ أو +. غلّف النص بـ re.escape(...). وإن لم يظهر التلميح فالرسالة مختلفة فعلاً: قارن سطري Expected regex و Actual message محرفاً محرفاً.
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 النمط ليس تعبيراً نمطياً صالحاً أصلاً — هنا قوس فتح ( بلا قوس إغلاق. والحل نفسه: re.escape.
يفشل الاختبار باستثناء آخر، مثل wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 رُفع استثناء، لكنه ليس الصنف المسمّى ولا صنفاً فرعياً منه، فتركه pytest.raises يمر. قرّر أيهما يُفترض أن يرفعه الكود، ثم أصلح الاختبار أو الكود.
AssertionError: .value can only be used after the context manager exits لمست excinfo.value داخل كتلة with. انقل السطر إلى ما بعد الكتلة.
TypeError: Expected a BaseException type, but got 'str' كتبت pytest.raises("ValueError"). مرّر الصنف نفسه دون علامتي اقتباس.
FAILED ... - ExceptionGroup... من كتلة pytest.raises(ValueError) رفع الكود ExceptionGroup تحتوي على ValueError. المجموعة ليست ValueError؛ استخدم pytest.raises(ExceptionGroup) مع excinfo.group_contains(ValueError)، أو pytest.RaisesGroup(ValueError).
ينجح الاختبار، وأنت متأكد أنه لا ينبغي أن ينجح ابحث عن أسطر بعد سطر الرفع داخل كتلة with — فهي لا تُنفَّذ أبداً — وعن pytest.raises(Exception) الذي يقبل كل شيء تقريباً. ثم اكسر الكود عمداً وتأكد أن الاختبار يصبح أحمر.
Step 4 of 6 — Predict
Check your understanding
ما السطر الأخير الذي يطبعه pytest -q؟
import pytest
def test_missing_key():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_missing_index():
prices = [15.0, 60.0]
with pytest.raises(KeyError):
prices[5]- A2 passed in 0.01s
- B2 failed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 passed, 1 error in 0.01s
هذا الاختبار ينجح. فما الخطأ فيه؟
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_names_the_amount():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.amount == 999.0- Aلا شيء — قيمة `excinfo.value.amount` هي فعلاً 999.0
- Bالـ assert موجود داخل الكتلة بعد سطر رفع الاستثناء، لذلك لا يُنفَّذ أبداً
- Cلا يمكن تسمية `excinfo` في عبارة `with`
- Dيُفشل `pytest.raises` الاختبار لأن الـ assert رفع `AssertionError`
تستدعي withdraw(100.0, -5.0) استثناء ValueError بهذه الرسالة بالضبط: amount must be positive (got -5.0). ماذا يحدث للاختبار؟
import pytest
from wallet import withdraw
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0)- Aينجح — النصان متطابقان تماماً
- Bيفشل مع `DID NOT RAISE ValueError`
- Cيفشل مع `Invalid regex pattern`
- Dيفشل مع `Regex pattern did not match`، لأن القوسين يشكّلان مجموعة في التعبير النمطي
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.
دورك الآن
اكتب inventory.py يحتوي دالة واحدة، remove_stock(stock: dict[str, int], item: str, quantity: int) -> int، وصنفَي استثناء:
-
UnknownItem، صنف فرعي منKeyError، يُرفع حين لا يكونitemفيstock، ويحمل اسم الصنف كخاصية -
OutOfStock، يُرفع حين تكونquantityأكبر من المتبقي، ويحمل الخاصيتينavailableوrequested -
ValueErrorعادي تحمل رسالته القيمة حين لا تكونquantityموجبة
تعيد الدالة الكمية المتبقية، ولا تغيّر القاموس إلا عند النجاح.
ثم اكتب test_inventory.py بستة اختبارات على الأقل:
- سحب ناجح — بلا
pytest.raises، فقط استدعِ وتحقق من النتيجة - صنف غير معروف، يُلتقط كـ
KeyError، معexcinfo.typeيُظهر أنه كانUnknownItem - كمية صفرية، مع
match=يتحقق من أن الرسالة تذكر القيمة — استخدمre.escapeإن احتوت الرسالة أقواساً أو نقاطاً - سحب يتجاوز المخزون، مع التحقق من
excinfo.value.availableوexcinfo.value.requested - السحب الزائد نفسه، مع التحقق بعد الكتلة من أن القاموس لم يتغير
- سحب المتبقي بالضبط، وهو يجب ألا يرفع استثناءً
ثم اكسر الكود ثلاث مرات، مرة في كل جولة، واقرأ كل إخفاق: احذف فحص الكمية الصفرية (ابحث عن DID NOT RAISE)، وغيّر > إلى >= في فحص المخزون، وأخيراً انقل أحد أسطر assert إلى داخل كتلة with تحت سطر الرفع وأعطه قيمة خاطئة. يجب أن يصبح الأولان أحمرين. أما الثالث فسيبقى أخضر — وهذا هو الدرس الذي يستحق أن تتذكره من هذا الفصل.
الحل
ابدأ من الخطة كما في بقية الفصل: حالة عادية واحدة، والحدّ الذي يجب السماح به (سحب كل شيء)، وكل حالة رفض مفحوصة من حيث الصنف، والرسالة أو الخصائص، والحالة التي بقيت بعدها.
inventory.py:
class UnknownItem(KeyError):
def __init__(self, item: str):
self.item = item
super().__init__(item)
class OutOfStock(Exception):
def __init__(self, item: str, available: int, requested: int):
self.item = item
self.available = available
self.requested = requested
super().__init__(f"only {available} {item} left, {requested} requested")
def remove_stock(stock: dict[str, int], item: str, quantity: int) -> int:
if quantity <= 0:
raise ValueError(f"quantity must be positive (got {quantity})")
if item not in stock:
raise UnknownItem(item)
available = stock[item]
if quantity > available:
raise OutOfStock(item, available, quantity)
stock[item] = available - quantity
return stock[item]test_inventory.py:
import re
import pytest
from inventory import OutOfStock, UnknownItem, remove_stock
def test_removal_returns_what_is_left():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 3) == 7
assert stock == {"pen": 7}
def test_unknown_item_is_a_key_error():
stock = {"pen": 10}
with pytest.raises(KeyError) as excinfo:
remove_stock(stock, "eraser", 1)
assert excinfo.type is UnknownItem
assert excinfo.value.item == "eraser"
def test_zero_quantity_names_the_value():
stock = {"pen": 10}
with pytest.raises(ValueError, match=re.escape("(got 0)")):
remove_stock(stock, "pen", 0)
def test_overdraw_reports_both_numbers():
stock = {"pen": 10}
with pytest.raises(OutOfStock) as excinfo:
remove_stock(stock, "pen", 11)
assert excinfo.value.available == 10
assert excinfo.value.requested == 11
def test_overdraw_changes_nothing():
stock = {"pen": 10}
with pytest.raises(OutOfStock):
remove_stock(stock, "pen", 11)
assert stock == {"pen": 10}
def test_removing_everything_is_allowed():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 10) == 0
assert stock == {"pen": 0}pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/inventory
collecting ... collected 6 items
test_inventory.py::test_removal_returns_what_is_left PASSED [ 16%]
test_inventory.py::test_unknown_item_is_a_key_error PASSED [ 33%]
test_inventory.py::test_zero_quantity_names_the_value PASSED [ 50%]
test_inventory.py::test_overdraw_reports_both_numbers PASSED [ 66%]
test_inventory.py::test_overdraw_changes_nothing PASSED [ 83%]
test_inventory.py::test_removing_everything_is_allowed PASSED [100%]
============================== 6 passed in 0.01s ===============================الآن الكسور الثلاثة. بعد حذف الفحص quantity <= 0، pytest -q --tb=no:
..F... [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_zero_quantity_names_the_value - Failed: DID NO...
1 failed, 5 passed in 0.01sبعد إعادته وتغيير quantity > available إلى quantity >= available:
.....F [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_removing_everything_is_allowed - inventory.Out...
1 failed, 5 passed in 0.01sبعد إعادة ذلك أيضاً ونقل آخر assert في test_overdraw_changes_nothing إلى داخل الكتلة تحت سطر الرفع، بصيغة assert stock == {"pen": 999}:
...... [100%]
6 passed in 0.01sسبب كل قرار:
-
UnknownItemصنف فرعي منKeyError. الصنف المفقود هو فعلاً مفتاح مفقود، لذا يبقى الكود الذي يلتقطKeyErrorمسبقاً يعمل. يلتقط الاختبارKeyError— الوعد المعلن — ثم يثبّت الصنف الدقيق بـexcinfo.type. - فحص الكمية أولاً. لا يحتاج شيئاً من
stock، فيفشل مبكراً، وتكون الرسالة عن الوسيط الذي أخطأ فيه المستدعي. -
re.escape("(got 0)"). يحمل الجزء القيمة، وهي ما يحتاجه قارئ الخطأ، وتُبقيre.escapeالقوسين حرفيين. مطابقة الجملة كاملة كانت ستنكسر يوم يعيد أحدهم صياغتها. - الخصائص بدلاً من تحليل الرسالة.
availableوrequestedعددان يمكن مقارنتهما مباشرة؛ واستخراجهما من نص كان سيختبر التنسيق لا البيانات. - اختباران للسحب الزائد. أحدهما يتحقق مما يقوله الاستثناء، والآخر مما تركه الكود وراءه. إن فشل أحدهما أخبرك اسمه أي وعد انكسر.
- اختبار الحدّ هو الذي كشف خطأ
>=. لم يكن أيٌّ من اختبارات الأخطاء ليكشفه: كلها كانت ما تزال ترفع. وحده الاختبار الذي يتوقع ألا يقع خطأ عند الحافة لاحظه. - بقي الكسر الثالث أخضر. هذا هو فخ السطر-بعد-الرفع من أول الفصل: لم يُنفَّذ الـ assert الكاذب قط. والدفاع الوحيد هو العادة — استدعاء واحد داخل الكتلة، والتحقق بعدها.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz