الفصل 04

اختبار الاستثناءات — pytest.raises

الأخطاء سلوك أيضاً، ولذلك تحتاج إلى اختبارات. pytest.raises و DID NOT RAISE ومطابقة الأصناف الفرعية، ولماذا match= تعبير نمطي، وفحص الخطأ الملتقط عبر excinfo، ولماذا لا يوضع داخل كتلة with إلا السطر الأخير.

45 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

إليك دالة صغيرة تحرس رصيداً. بُني فيها رفضان: مبلغ ليس موجباً، ومبلغ أكبر من الموجود.

wallet.py:

python
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) سترفض، وإن اختفى هذا الرفض يوماً ما تحرّك المال في الاتجاه الخطأ. لذلك يستحق اختباراً. وأول اختبار يكتبه معظم الناس يبدو هكذا:

python
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:

text
.                                                                        [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. تحقق من الأمرين الأولين مرة واحدة:

text
$ python -m pytest --version
pytest 9.1.1

ثم انظر إلى السلوك الحقيقي للكود قبل أن تكتب عنه ادعاءً واحداً. تجربة قصيرة تجيب عن أسئلة لا يجيب عنها التخمين — هل تُعاد 0.0 أم يُرفض الطلب؟ كيف تبدو الرسالة؟

python
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))
text
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 أن يرفع الاستثناء المسمّى:

python
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)
text
..                                                                       [100%]
2 passed in 0.01s

إذا خرج ValueError من الكتلة التقطه pytest.raises وتابع الاختبار بعد الكتلة. هذا النصف كان موجوداً في try/except أصلاً. النصف الآخر هو ما يحدث حين لا يخرج شيء. احذف الفحص if amount <= 0 مرة أخرى وشغّل الملف نفسه:

text
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.01s

‏DID NOT RAISE ValueError هي الجملة التي لم تكن نسخة try/except لتقولها أبداً. وهذا كل ما في pytest.raises: التقاط الخطأ المتوقع، والفشل بصوت عالٍ حين لا يصل.

أي الاستثناءات تُعدّ مطابقة

يطابق pytest.raises(X) تماماً كما يفعل except X:: الصنف X نفسه أو أي صنف فرعي منه. تشكّل استثناءات بايثون شجرة عائلة، ويمكنك رؤية فرع منها:

python
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))
text
(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
True

إذن KeyError هو LookupError. هذا مفيد، وهو الفخ أيضاً. إليك اختبارين ينجح كلاهما:

python
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
text
..                                                                       [100%]
2 passed in 0.01s

الأول سليم — التقاط الصنف الأب عن قصد. والثاني اختبار لا يثبت شيئاً. لم يصل withdraw(100.0) إلى جسم الدالة أصلاً: رفعت بايثون TypeError بسبب الوسيط الناقص، ولأن كل استثناء عادي صنف فرعي من Exception قبله pytest.raises(Exception) بسرور. خطأ إملائي، أو استيراد خاطئ، أو AttributeError — أيٌّ منها كان سيجعل هذا الاختبار أخضر.

القاعدة: سمِّ أدق استثناء يَعِد الكود برفعه. كلما اتسع الصنف، زادت الإخفاقات غير ذات الصلة التي يخفيها.

أما الاتجاه الآخر فصارم. الاستثناء الذي ليس هو الصنف المسمّى ولا صنفاً فرعياً منه لا يُلتقط؛ بل يفلت من الكتلة ويُفشل الاختبار كخطأ عادي. (يختصر pytest -q --tb=short كل تتبّع (traceback) إلى سطر واحد لكل إطار.)

python
import pytest

from wallet import withdraw


def test_overdraft_is_rejected():
    with pytest.raises(ValueError):
        withdraw(100.0, 150.0)
text
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=:

python
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)
text
.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" في المنتصف. لتثبيت البداية أو النهاية استخدم ^ و $.
  • لبعض المحارف معنى خاص. في التعبير النمطي يشكّل ( ) مجموعة ولا يطابقان أي قوس؛ ويطابق . أي محرف؛ ولكلٍّ من + و * و ? و [ و $ وظيفته أيضاً.

حالة . هي الأهدأ، لأنها تخطئ في اتجاه النجاح:

python
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"))
text
<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
None

طابق "100.00" النص 100500 دون أي اعتراض. تحوّل re.escape كل محرف خاص إلى محرف حرفي، لذا حين تعني "هذا النص بالضبط" مرّر النص عبرها — تماماً كما اقترح تلميح pytest:

python
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)
text
.                                                                        [100%]
1 passed in 0.01s

مقدار ما تطابقه من الرسالة مسألة تقدير. جزء قصير مميز — القيمة، الكلمة المفتاحية — يصمد أمام إعادة الصياغة البريئة؛ أما الجملة الكاملة فتنكسر في اللحظة التي يصحّح فيها أحدهم خطأً إملائياً.

excinfo — الاستثناء الذي التقطته

تمنحك with pytest.raises(...) as excinfo مقبضاً لما التُقط. ألقِ نظرة داخله مرة واحدة باستخدام pytest -q -s (الخيار -s يسمح بظهور print):

python
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)
text
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:

python
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"
text
.                                                                        [100%]
1 passed in 0.01s

حين يحمل الاستثناء بيانات في خصائصه، يكون التحقق من الخصائص أمتن من تحليل الرسالة. لاحظ أن أسطر assert تقع بعد كتلة with لا داخلها. هذا ليس أسلوباً؛ والقسم التالي يشرح السبب.

سطر الرفع وحده داخل الكتلة

في اللحظة التي يُرفع فيها استثناء تغادر بايثون الكتلة. يُتخطّى كل سطر بعد سطر الرفع — بما في ذلك أسطر assert:

python
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)
text
..                                                                       [100%]
2 passed in 0.01s

الاختباران أخضران، وكلاهما خاطئ. في الأول، ‏assert balance == 999.0 ادعاء كاذب لم يُنفَّذ قط. وفي الثاني رفع float("ten") الاستثناء ValueError في سطر التهيئة، فلم تُستدعَ withdraw أصلاً — ينجح الاختبار سواء فحصت withdraw شيئاً أم لا.

القاعدة: التهيئة قبل الكتلة، واستدعاء رافع واحد داخلها، والتحقق بعدها.

python
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
text
.                                                                        [100%]
1 passed in 0.01s

الآن يُنفَّذ الـ assert، ويتحقق من شيء حقيقي: السحب المرفوض ترك balance كما هو، لأن الإسناد لم يحدث قط.

أكثر من استثناء مقبول

يقبل pytest.raises صفّاً (tuple) أيضاً، تماماً مثل except مرة أخرى. أي صنف من المذكورة يفي بالغرض، ويخبرك excinfo.type بأيها وصل:

python
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__)
text
InsufficientFunds
.
1 passed in 0.01s

استخدم هذا حين يكون العقد فعلاً "واحداً من هذه" — كأن يكون الكود قادراً على رفع أيٍّ منها بشكل مشروع حسب المنصة. أما لدوالك أنت، فصنف واحد دقيق هو الوعد الأفضل في كل الحالات تقريباً.

اختبار أن شيئاً لا يرفع استثناءً

لا توجد دالة pytest.does_not_raise() تلجأ إليها، ولا تحتاج إليها. استدعِ الكود. إن رفع استثناءً فشل الاختبار بذلك الاستثناء وتتبّعه:

python
from wallet import withdraw


def test_whole_balance_can_be_withdrawn():
    assert withdraw(100.0, 100.0) == 0.0
text
.                                                                        [100%]
1 passed in 0.01s

غيّر amount > balance إلى amount >= balance في withdraw — خطأ "الفارق بواحد" الذي يكتبه كثيرون — فيُبلغ pytest -q --tb=short:

text
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 نسخة دفعية تحاول كل عملية سحب وتُبلغ عن كل الإخفاقات معاً:

python
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:

python
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 لترى النتيجة فقط:

text
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:

python
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] += amount

test_bank.py:

python
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.0

pytest -v:

text
============================= 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) الذي يقبل كل شيء تقريباً. ثم اكسر الكود عمداً وتأكد أن الاختبار يصبح أحمر.