الفصل 00

قبل أن تكتب اختباراً — ما الذي تحتاجه فعلاً

التفكير الذي يسبق أي كود اختبار: تحويل متطلب غامض إلى عقد دقيق، وجعل الكود قابلاً للاختبار، واختيار الحالات مع الحدود، ومعرفة الإجابة المتوقعة دون تشغيل الكود، وتحديد ما لا يُختبر — كل ذلك ببايثون العادية و assert.

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

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

إليك دالة تحسب متوسط قائمة من درجات الامتحان، والطريقة التي يتحقق بها معظمنا من دالة كهذه — نستدعيها مرتين وننظر:

python
def average(scores):
    return sum(scores) / len(scores)


print(average([80, 90, 70]))
print(average([100]))
text
80.0
100.0

الإجابتان صحيحتان. الدالة "تعمل"، فتدخل البرنامج. وبعد أسبوع يصل إلى السطر نفسه فصلٌ لم تُرصد له أي نتائج بعد:

python
def average(scores):
    return sum(scores) / len(scores)


print(average([]))
text
Traceback (most recent call last):
  File "/home/you/school/report.py", line 5, in <module>
    print(average([]))
          ^^^^^^^^^^^
  File "/home/you/school/report.py", line 2, in average
    return sum(scores) / len(scores)
           ~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero

عبارة "كانت تعمل عندما جرّبتها" كانت صحيحة. لكنها لم تعنِ يوماً إلا "كانت تعمل مع المدخلين اللذين صادف أنني جرّبتهما". وهذا يكشف نقطتي ضعف في التحقق اليدوي.

الأولى أنه لا يغطي إلا ما خطر لك في تلك اللحظة، والقائمة الفارغة هي بالضبط من نوع الأشياء التي لا تخطر لأحد في تلك اللحظة.

والثانية أنه لا يتكرر. استدعاءا print شُغّلا مرة، وقُرئا مرة، ثم حُذفا. وحين تتغير الدالة في الشهر القادم، لن يشغّلهما أحد من جديد.

الاختبارات الآلية تعالج نقطة الضعف الثانية، وبقية هذه الدورة تدور حول كتابتها باستخدام pytest. لكن تأمّل نقطة الضعف الأولى عن قرب، لأن أي أداة لا تعالجها. لنفترض أنك أردت الآن كتابة تحقق للقائمة الفارغة. ماذا سيقول؟ هل يجب أن تعيد average([]) القيمة 0؟ أم None؟ أم ترفع خطأً؟ لم يقرر أحد. لا يمكنك كتابة اختبار لسلوك لم يقرره أحد، ولا يمكنك التحقق من إجابة لا تعرفها مسبقاً.

وهذا موضوع هذا الفصل: التفكير الذي يجب أن يحدث قبل أول سطر من كود الاختبار. أتقِنه وستكاد الاختبارات تكتب نفسها. وتجاوزه وستنتهي إلى اختبارات تنجح ومع ذلك يفوتها الخلل.

بنهاية هذا الفصل ستكون قادراً على

  • قول ما هو الاختبار في جملة واحدة: ادعاء قابل للتشغيل عن السلوك
  • تحويل متطلب غامض إلى عقد دقيق — المدخلات والمخرج والأخطاء والآثار الجانبية
  • تمييز الكود الذي يصعب اختباره، وإعادة تشكيله إلى نواة نقية وغلاف رقيق
  • تخطيط الحالات باستخدام فئات التكافؤ والقيم الحدّية، على شكل جدول
  • استنتاج الإجابات المتوقعة باستقلال عن الكود — أي المرجع (oracle)
  • تحديد ما لا ينبغي اختباره، ومتى يكون لديك ما يكفي من الاختبارات
  • كتابة الخطة على شكل عمليات تحقق بـ assert المجردة، وتشغيلها، وتوضيح لماذا لا تكفي بعد

المتطلبات المسبقة: لا شيء — من هنا تبدأ الدورة. تحتاج إلى Python 3 وإلى معرفة كيفية كتابة دالة. ولا تحتاج إلى pytest بعد؛ فكل ما في هذا الفصل يعمل ببايثون العادية.


قبل أن تكتب الاختبار

في كل فصل من فصول هذه الدورة قسم بهذا الاسم، وكلها تجيب عن الأسئلة الستة نفسها، كلٌّ في موضوعه. وهذا الفصل هو منبع تلك الأسئلة، فإليك إياها في مكان واحد. احتفظ بها؛ فبقية الدورة تستخدمها قائمةَ تحقق.

  1. ما الذي يُوعَد به بالضبط؟ العقد: لهذه المدخلات هذا المخرج؛ ولهذه المدخلات هذا الخطأ؛ وهذه الآثار الجانبية، أو لا شيء منها.
  2. هل يمكن لاختبار أن يستدعي الكود؟ هل يأخذ مدخلاته وسائطَ ويعيد نتيجة — أم يقرأ من لوحة المفاتيح ويطبع وينظر إلى الساعة؟
  3. أي الحالات؟ حالة من كل نوع من المدخلات، وجانبا كل حدّ، والمدخلات غير الصالحة، والفارغ والكبير جداً.
  4. كيف أعرف الإجابة الصحيحة؟ بالحساب اليدوي أو من المواصفات — لا بتشغيل الكود قيد الاختبار أبداً.
  5. ما الذي يجب أن يكون جاهزاً؟ إصدار بايثون، وبيئة، ومكان ملفات الاختبار — وفي الفصول اللاحقة الملفات والإعدادات والأدوات التي يحتاجها الموضوع.
  6. ما الذي لن أختبره؟ بايثون نفسها، ومكتبات الآخرين، والكود الأبسط من أن يخطئ، والسلوك الذي لم يَعِد به أحد.

وإليك تطبيقها على average من الأعلى، فهي صغيرة بما يكفي لنتناولها كاملة.

في العقد ثغرة، فالخطوة الأولى سدّها. لنفترض أنك سألت، وكانت الإجابة: "متوسط لا درجات فيه لا معنى له؛ ارفع ValueError". الدالة قابلة للاختبار أصلاً — تأخذ قائمة وتعيد رقماً. وفي هذا الفصل لا يلزم أن يكون جاهزاً إلا Python 3. أما الحالات:

| الحالة | المدخل | المتوقع | السبب | | --- | --- | --- | --- | | معتادة | [80, 90, 70] | 80.0 | الاستخدام العادي: (80 + 90 + 70) / 3 | | درجة واحدة | [100] | 100.0 | أصغر قائمة لها متوسط | | فارغة | [] | ValueError | الحالة التي انكسرت؛ وقد حُسم أمرها الآن |

وما ليس في القائمة: هل تجمع sum بشكل صحيح، أو هل تقسم / — هذه وظيفة بايثون، وهي مختبرة بدقة تفوق كثيراً أي شيء سنكتبه أنا أو أنت.

بقية الفصل تتناول الأسئلة الستة واحداً واحداً، وتنتهي بتطبيق العملية كلها على دالة واقعية واحدة.

ما هو الاختبار فعلاً

الاختبار ادعاء قابل للتشغيل عن السلوك: بهذا المدخل، توقّع هذه النتيجة. و"النتيجة" ثلاثة أنواع بالضبط:

  • قيمة معادة — average([80, 90, 70]) تعطي 80.0
  • خطأ — average([]) ترفع ValueError
  • أثر جانبي — بعد add_score(scores, 70) تصبح في نهاية القائمة scores القيمة 70

لدى بايثون أصلاً تعليمة لتدوين ادعاء: assert. لا تفعل شيئاً حين يكون الشرط صحيحاً، وترفع AssertionError حين يكون خاطئاً. إليك ادعاءً من كل نوع، وaverage تتبع الآن العقد الذي حُسم:

python
def average(scores):
    if not scores:
        raise ValueError("average() of an empty list")
    return sum(scores) / len(scores)


def add_score(scores, new_score):
    scores.append(new_score)


# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0

# 2. An error: given this input, expect this exception.
try:
    average([])
except ValueError:
    pass
else:
    raise AssertionError("average([]) should raise ValueError")

# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]

print("3 claims checked")
text
3 claims checked

لاحظ ما يحتاجه كل ادعاء: مدخلاً تختاره أنت، ونتيجة تعرفها مسبقاً، وطريقة لاستدعاء الكود والنظر فيما فعله. الأسئلة الستة موجودة لتضمن أن الثلاثة لديك قبل أن تبدأ. ولاحظ كذلك كم هو ثقيل التحقق من الخطأ — خمسة أسطر من try/except/else لتقول "يجب أن يُرفع خطأ هنا". يحوّل pytest ذلك إلى سطر واحد في الفصل الرابع.

1. العقد: من متطلب غامض إلى وعد دقيق

تصل المتطلبات عادةً على شكل جملة: "يحصل الأعضاء على خصم 10% على الطلبات الكبيرة." تبدو كاملة. أعطها لمطوّرَين حريصَين وراقب:

python
# "Members get 10% off big orders." Two honest readings of the same sentence.

def discount_by_asha(total, is_member):
    if is_member and total > 1000:
        return total * 0.10
    return 0


def discount_by_ravi(total, is_member):
    if is_member and total >= 1000:
        return round(total * 0.10)
    return 0


for total in [500, 1000, 1234.56]:
    print(total, discount_by_asha(total, True), discount_by_ravi(total, True))
text
500 0 0
1000 0 100
1234.56 123.456 123

لم يخطئ أيٌّ منهما. الجملة لم تقل هل يُعدّ 1000 بالضبط طلباً "كبيراً"، ولا كيف يكون التقريب. ملأ كلٌّ منهما الثغرة بطريقته، فيحصل طلب قيمته 1000 على خصم 0 من أحدهما و100 من الآخر. لا يستطيع أي اختبار أن يقول أيهما على صواب، لأن "الصواب" لم يُعرَّف قط.

العقد هو المتطلب وقد صار دقيقاً بما يكفي ليُختبر. وللوصول من الجملة إلى العقد، اطرح أسئلة — الأسئلة نفسها في كل مرة:

  • المدخلات. ما أنواعها؟ ما نطاقها؟ هل هناك حدّ، وهل الحدّ نفسه مشمول؟ وماذا عن الصفر والسالب والفارغ وNone؟
  • المخرج. ما الذي يُعاد بالضبط — الخصم أم الإجمالي الجديد؟ ما نوعه؟ ويُقرَّب إلى ماذا؟
  • الأخطاء. أي المدخلات تُرفض، وكيف — بأي استثناء؟
  • الآثار الجانبية. هل يغيّر شيئاً — قائمة مُمرَّرة إليه، أو ملفاً، أو قاعدة بيانات — أو يطبع شيئاً؟ أم لا شيء على الإطلاق؟

حين تُطرح هذه الأسئلة عن الخصم تنتج إجابات، والإجابات توضع حيث يوجد الكود — في سلسلة التوثيق (docstring):

python
def member_discount(total, is_member):
    """Return the discount on an order, in currency units.

    - total: the order total before discount, a number >= 0.
      A negative total raises ValueError.
    - is_member: True or False.
    - Members get 10% of the total when the total is 1000 or more
      (1000 itself qualifies). Everyone else gets 0.
    - The result is rounded to 2 decimal places.
    - Returns the discount amount, not the new total.
    - No side effects: reads nothing, prints nothing, changes nothing.
    """
    if total < 0:
        raise ValueError(f"total must not be negative, got {total}")
    if is_member and total >= 1000:
        return round(total * 0.10, 2)
    return 0


print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
    member_discount(-5, True)
except ValueError as error:
    print("ValueError:", error)
text
0
100.0
123.46
0
ValueError: total must not be negative, got -5

كل سطر في سلسلة التوثيق صار الآن شيئاً يستطيع الاختبار التحقق منه، ولكل تحقق قيمة متوقعة واضحة. وهذا هو اختبار العقد الجيد: هل يستطيع شخص غريب أن يكتب القيمة المتوقعة لأي مدخل دون أن يقرأ الكود؟ إن لم يستطع، فما زال هناك سؤال ينبغي طرحه.

من يجيب عن الأسئلة؟ صاحب المتطلب أياً كان — مالك المنتج، أو معلّم، أو عميل، أو أنت. وحين لا يوجد من تسأله، قرّر، واكتب القرار في العقد، وامضِ قُدماً. القرار الصريح يمكن مراجعته وتغييره؛ أما القرار الصامت فيبقى في الكود إلى أن يفاجئ أحدهم.

2. كود قابل للاختبار: نواة نقية وغلاف رقيق

يستدعي الاختبار دالةً بمدخلات مختارة ويقارن ما يعود منها. بعض الدوال تجعل ذلك مستحيلاً. إليك واحدة لساعة التخفيضات في مقهى — خصم 20% على المشروبات بين 17:00 و19:00:

python
from datetime import datetime


def drink_price():
    price = float(input("Price: "))
    hour = datetime.now().hour
    if 17 <= hour < 19:
        price = price * 0.8
    print(f"You pay {price:.2f}")

تعمل حين تشغّلها وتكتب سعراً. والآن حاول التحقق منها من ملف آخر، check_drinks.py:

python
from drinks import drink_price

result = drink_price()
print("returned:", result)

التحقق الآلي يعمل ولا أحد أمام لوحة المفاتيح. و< /dev/null يمنح البرنامج ذلك تماماً — مدخلاً لا شيء فيه:

text
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
  File "/home/you/cafe/check_drinks.py", line 3, in <module>
    result = drink_price()
             ^^^^^^^^^^^^^
  File "/home/you/cafe/drinks.py", line 5, in drink_price
    price = float(input("Price: "))
                  ^^^^^^^^^^^^^^^^
EOFError: EOF when reading a line

أعطها سعراً فتعمل — لكن انظر إلى ما يعود:

text
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: None

ثلاث مشكلات منفصلة، وكل واحدة منها شائعة:

  • تقرأ مدخلها باستخدام input() بدلاً من أن تأخذه وسيطاً، فلا يستطيع الاختبار أن يختار المدخل.
  • تطبع نتيجتها بدلاً من أن تعيدها، فيحصل الاختبار على None ولا يجد ما يقارنه.
  • تقرأ الساعة بنفسها. ذلك التشغيل جرى قبيل السابعة صباحاً، فكانت الإجابة 10.00؛ والأمر نفسه في الخامسة والنصف مساءً يعطي 8.00. والتحقق منها سينجح أو يفشل بحسب وقت تشغيله — أي اختبار متقلّب (flaky)، وهو أسوأ من عدم وجود اختبار، لأن الناس يتعلمون تجاهله.

الحل ليس حيلة اختبارية، بل تغيير في الشكل: افصل القرار عن الإدخال والإخراج. يصبح القرار دالة نقية — كل ما تحتاجه يدخل وسائطَ، والإجابة تخرج قيمةً معادة، ولا تلمس شيئاً آخر. أما لوحة المفاتيح والشاشة والساعة فتنتقل إلى غلاف رقيق حولها:

python
from datetime import datetime


def drink_price(price, hour):
    """Return the price to pay for a drink.

    20% off from 17:00 up to, but not including, 19:00.
    """
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


def main():
    # The thin shell: all the input, output and clock reading lives here.
    price = float(input("Price: "))
    print(f"You pay {drink_price(price, datetime.now().hour):.2f}")


if __name__ == "__main__":
    main()

والآن تختار عمليات التحقق أي ساعة تشاء، في أي وقت من اليوم:

python
from drinks import drink_price

assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")
text
all checks passed

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

علامات التحذير في الكود الذي يصعب اختباره، كي تلمحها قبل أن تكتب اختباراً:

| العلامة | لماذا تضرّ بالاختبار | الحل المعتاد | | --- | --- | --- | | input() داخل المنطق | لا يستطيع الاختبار اختيار المدخل | خذه وسيطاً | | print() بوصفها النتيجة الوحيدة | لا يعود شيء للمقارنة | أعِد القيمة؛ واطبع في الغلاف | | datetime.now() أو random في الداخل | تتغير الإجابة بين مرة وأخرى | مرّر الوقت أو القيمة العشوائية من الخارج | | تقرأ متغيراً عاماً أو تغيّره | تؤثر الاختبارات بعضها في بعض | مرّره إليها، وأعِد القيمة الجديدة | | تفتح ملفاً أو عنوان URL ثابتاً في الداخل | يحتاج الاختبار ذلك الملف أو الشبكة | مرّر البيانات، أو المسار، من الخارج |

لا يمكنك دائماً إعادة تشكيل الكود — فأحياناً يكون ملكاً لغيرك، أو يكون الإدخال والإخراج هو السلوك نفسه. ولدى pytest أدوات لهذه الحالات: capsys يلتقط المخرجات المطبوعة (الفصل التاسع)، وtmp_path يمنحك مجلداً مؤقتاً (الفصل التاسع)، وmonkeypatch والكائنات البديلة (mocks) تستبدل الساعة والشبكة والبيئة (الفصلان العاشر والحادي عشر). لكن إعادة التشكيل تأتي أولاً. فالكود السهل الاختبار هو في العادة ببساطة أسهل فهماً.

3. اختيار الحالات

لا يمكنك اختبار كل مدخل؛ فـ drink_price وحدها تقبل كل سعر في كل ساعة. المهمة أن تختار مجموعة صغيرة يمكن لكل حالة فيها أن تلتقط خطأً تفوّته الحالات الأخرى. وفكرتان تؤديان معظم هذا العمل.

فئات التكافؤ. اجمع المدخلات التي يعاملها العقد بالطريقة نفسها. أي عضو في المجموعة ينوب عن البقية: إذا كانت drink_price(10, 12) صحيحة، فالأرجح جداً أن drink_price(10, 11) صحيحة أيضاً، لأن السطر نفسه من الكود يعالجهما. لنفترض أن العقد شُدّد ليقول إن أي ساعة خارج 0–23، أو أي سعر سالب، يرفع ValueError. عندها تنقسم الساعات إلى خمس فئات:

text
hour:   ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
        invalid   | full price    | 20% off | full price | invalid

القيم الحدّية. الأخطاء لا تنتشر بالتساوي داخل الفئة، بل تتجمع عند أطرافها، لأن هناك يُختار بين < و<=، وهناك تتحول عبارة "حتى 19:00" إلى كود. لذا اختبر لكل حدّ القيمة على كل جانب منه: آخر قيمة في فئة وأول قيمة في التالية. ولحدّ L يعني ذلك النظر في L - 1 وL وL + 1؛ وفي الأعداد والأطوال، في 0 و1 أيضاً.

وإليك لماذا لا تكفي "قيمة معتادة واحدة لكل فئة". أدناه كُتب شرط ساعة التخفيضات بزلّة سهلة الوقوع، 17 < hour بدلاً من 17 <= hour:

python
def drink_price(price, hour):
    # A slip: 17 < hour instead of 17 <= hour.
    if 17 < hour < 19:
        return round(price * 0.8, 2)
    return price


# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")

# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")
text
typical cases passed
Traceback (most recent call last):
  File "/home/you/cafe/boundary.py", line 15, in <module>
    assert drink_price(10, 17) == 8.0
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

حالة من كل فئة: كلها تنجح، والزبائن الذين يصلون في الخامسة يدفعون السعر كاملاً. أما الحالة الحدّية فتلتقط الخلل فوراً.

الفئات والحدود تغطي شكل المدخلات. بعد ذلك مرّ على هذه الأسئلة، فكل واحد منها كشف أخطاءً حقيقية:

  • المدخلات غير الصالحة — ما يقول العقد إنه يُرفض: سعر سالب، الساعة 24.
  • الفارغ والصفر — [] و"" و0 وسعر قدره 0. هل يتصرف "اللاشيء" كما ينبغي؟
  • None — فقط إن ذكره العقد. وإن لم يذكره فليس حالة (انظر السؤال السادس).
  • القيم الكبيرة — قائمة طويلة جداً، أو رقم كبير جداً. وكل ما له حدّ، اختبره عند الحدّ.
  • التقريب — حيث ينتج الحساب منازل عشرية أكثر مما تحتفظ به النتيجة.

اجمع ذلك كله فتحصل على خطة اختبار: صف لكل حالة، تُكتب قبل أي كود اختبار. والعمود الأخير هو الأهم — الصف الذي لا تستطيع تبريره صفٌّ لا تحتاجه.

| الحالة | المدخل (price, hour) | المتوقع | السبب | | --- | --- | --- | --- | | أدنى ساعة صالحة | (10, 0) | 10 | طرف النطاق الصالح | | آخر ساعة بالسعر الكامل | (10, 16) | 10 | تحت حدّ 17:00 | | أول ساعة مخفّضة | (10, 17) | 8.0 | على حدّ 17:00 | | آخر ساعة مخفّضة | (10, 18) | 8.0 | تحت حدّ 19:00 | | أول عودة للسعر الكامل | (10, 19) | 10 | على حدّ 19:00 — "غير مشمول" | | أعلى ساعة صالحة | (10, 23) | 10 | طرف النطاق الصالح | | مشروب مجاني | (0, 17) | 0 | سعر صفري | | التقريب | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992، مقرّباً إلى منزلتين | | ساعة أدنى من النطاق | (10, -1) | ValueError | خارج النطاق مباشرة | | ساعة أعلى من النطاق | (10, 24) | ValueError | خارج النطاق مباشرة | | سعر سالب | (-1, 12) | ValueError | مدخل غير صالح |

وهذه الخطة وقد تحولت إلى عمليات تحقق، على الدالة بعد تشديد عقدها:

python
def drink_price(price, hour):
    """Return the price to pay for a drink.

    - price: a number >= 0. hour: a whole number 0-23.
    - 20% off from 17:00 up to, but not including, 19:00.
    - The result is rounded to 2 decimal places.
    - An hour outside 0-23 or a negative price raises ValueError.
    """
    if not 0 <= hour <= 23:
        raise ValueError(f"hour must be 0-23, got {hour}")
    if price < 0:
        raise ValueError(f"price must not be negative, got {price}")
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


def raises_value_error(price, hour):
    try:
        drink_price(price, hour)
    except ValueError:
        return True
    return False


assert drink_price(10, 0) == 10      # lowest valid hour
assert drink_price(10, 16) == 10     # last full-price hour before
assert drink_price(10, 17) == 8.0    # first discounted hour
assert drink_price(10, 18) == 8.0    # last discounted hour
assert drink_price(10, 19) == 10     # first full-price hour after
assert drink_price(10, 23) == 10     # highest valid hour
assert drink_price(0, 17) == 0       # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1)    # just below the valid range
assert raises_value_error(10, 24)    # just above the valid range
assert raises_value_error(-1, 12)    # negative price
print("11 checks passed")
text
11 checks passed

أحد عشر صفاً، ولكلٍّ منها سبب تستطيع أن تقوله بصوت عالٍ.

4. المرجع: معرفة الإجابة دون الكود

كل تحقق يقارن إجابة الكود بإجابة متوقعة. ومصدر الإجابة المتوقعة يسمى المرجع (oracle)، وله قاعدة واحدة: يجب ألا يكون هو الكود قيد الاختبار.

يبدو هذا أوضح من أن يُقال، إلى أن ترى مدى سهولة كسره. هنا في member_discount خلل — 1% بدلاً من 10% — ومعها تحققان:

python
def member_discount(total, is_member):
    if total < 0:
        raise ValueError(f"total must not be negative, got {total}")
    if is_member and total >= 1000:
        return round(total * 0.01, 2)   # bug: 1%, not 10%
    return 0


# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)

# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")
text
self-check passed, expected was 20.0
Traceback (most recent call last):
  File "/home/you/shop/oracle.py", line 15, in <module>
    assert member_discount(2000, True) == 200
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

التحقق الأول يقارن الدالة بنفسها. ينجح مع الدالة الصحيحة، ومع هذه المعطوبة، ومع أي نسخة أخرى قد يكتبها أي أحد — لا يمكنه أن يفشل، فلا يخبرك بشيء.

لا أحد يكتبه بهذه الصراحة. فالخطأ نفسه يأتي عادةً في أحد قناعين:

  • نسخ المخرجات. تشغّل الدالة فتطبع 20.0، فتلصق 20.0 في التحقق. إذا كان الكود خاطئاً أصلاً، فالتحقق الآن يحرس الخلل.
  • تكرار الصيغة. تكتب القيمة المتوقعة على أنها 2000 * 0.01 — الحساب نفسه الذي يجريه الكود. ونسخة من منطق الكود تشاركه أخطاءه. (قسم "الأخطاء الشائعة وحلولها" أدناه يعرض هذا الخطأ وهو يعمل.)

المراجع الجيدة، مرتبة تقريباً من الأكثر شيوعاً إلى الأقل:

  • الحساب اليدوي من العقد. اختر مدخلات تسهّل ذلك: 2000 بدلاً من 1873.41، كي يكون "10% منه" شيئاً تحسبه في ذهنك.
  • الأمثلة الواردة في المواصفات. إذا قال المتطلب "طلب بقيمة 1500 يحصل على خصم 150"، فذلك الصف اختبار.
  • الحقائق المعروفة. يغلي الماء عند 100 °C و212 °F؛ والقائمة الفارغة طولها 0.
  • طريقة مختلفة موثوقة. حساب أبسط وأبطأ، أو دالة من المكتبة القياسية تؤدي العمل نفسه بطريقة أخرى:
python
import statistics


def average(scores):
    if not scores:
        raise ValueError("average() of an empty list")
    return sum(scores) / len(scores)


samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
    assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")
text
agrees with statistics.mean on 4 samples

كتب statistics.mean أشخاص آخرون، بطريقة مختلفة، فالاتفاق معها يعني شيئاً. (مقارنة النتائج العشرية بـ == قد تلدغك — 0.1 + 0.2 == 0.3 قيمتها False في بايثون. القيم هنا آمنة؛ والفصل الخامس يبيّن كيف تقارن الأعداد العشرية كما ينبغي.)

إذا لم تستطع استنتاج الإجابة المتوقعة بأي من هذه الوسائل، فليست تلك مشكلة اختبار. إنها تعني أنك لا تعرف بعد ما ينبغي أن يفعله الكود — فعُد إلى العقد.

5. ما الذي يجب أن يكون جاهزاً

في هذا الفصل، Python 3 فقط. تحقق من النسخة التي لديك:

text
$ python3 --version
Python 3.12.3

تستخدم هذه الدورة Python 3.12، وأي إصدار من 3.10 فما فوق سيشغّل معظمها تقريباً. وعلى Windows يكون الأمر عادةً py --version.

ومن الفصل القادم فصاعداً، يجب أن تكون ثلاثة أشياء أخرى جاهزة قبل أن يعمل أي اختبار، والفصل الأول يُعدّ كلاً منها:

  • بيئة افتراضية للمشروع، كي تُثبَّت أداة الاختبار في نسخة بايثون نفسها التي تشغّل كودك.
  • تثبيت pytest فيها.
  • مكان للاختبارات. توضع ملفات الاختبار بجوار الكود، أو في مجلد tests/، بأسماء تبدأ بـ test_. يبدأ الفصل الأول بالخيار الأول؛ وينتقل الفصل الثالث إلى الثاني ويشرح السبب.

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

6. ما لا تختبره، ومتى تتوقف

الاختبار الذي لا يستطيع التقاط خطأ في كودك أنت يكلّف وقتاً في كتابته، ووقتاً في تشغيله، ووقتاً في صيانته، ولا يعطي شيئاً في المقابل. اترك هذه خارجاً:

  • بايثون نفسها. لا تختبر هل ترتّب sorted أو هل تقرّب round. اختبر أن دالتك أنت ترتّب الشيء الصحيح بالترتيب الصحيح.
  • مكتبات الآخرين. لا تختبر هل ترسل requests طلباً أو هل تقرأ pandas ملف CSV. اختبر ما يفعله كودك بالنتيجة — وإن احتجت إلى التيقن من سلوك مكتبة ما، فيكفي تحقق صغير واحد يوثّق افتراضك.
  • الكود الأبسط من أن يخطئ. دالة تعيد ثابتاً، أو صنف لا يفعل سوى حفظ وسائطه. إن لم تستطع تخيّل الخطأ، فلن يستطيع الاختبار التقاطه.
  • السلوك الذي لم يَعِد به أحد. إذا لم يقل العقد شيئاً عن تمرير نص إلى drink_price، فلن يفعل الاختبار سوى تجميد ما يصادف أنها تفعله اليوم. إما أن تضيفه إلى العقد — ثم تختبره — وإما أن تتركه.
  • الكيف بدلاً من الماذا. لا تختبر أي دالة مساعدة استدعتها الدالة داخلياً، ولا بأي ترتيب نفّذت خطواتها. اختبر ما يدخل وما يخرج؛ عندها يمكن إعادة كتابة الداخل بحرية، والاختبارات تثبت أنه ما زال يعمل.

وكم يكفي؟ لا يوجد رقم، لكن توجد قاعدة. لديك ما يكفي حين:

  • يكون لكل فئة تكافؤ حالة واحدة على الأقل،
  • ويكون لكل حدّ حالة على كل جانب منه،
  • ويكون لكل خطأ يَعِد به العقد حالة،
  • ويكون لكل خلل أصلحته يوماً حالة خاصة به، كي لا يعود بصمت.

ثم توقّف. ولكل حالة جديدة تُغريك إضافتها، اسأل: ما الخطأ الذي ستلتقطه هذه ولا تلتقطه البقية؟ إن لم تستطع تسمية واحد، فهي مكررة. drink_price(10, 11) بجوار drink_price(10, 12) لا تلتقط شيئاً جديداً.


مثال تطبيقي متكامل

والآن العملية كلها، من البداية إلى النهاية، على دالة واقعية واحدة.

المتطلب، كما وصل: "الشحن 60 للطرود المحلية و120 للطرود الوطنية. الطرود الأثقل تكلّف أكثر — 20 لكل كيلو إضافي محلياً، و30 وطنياً. لا نقبل أي شيء يزيد على 30 كغ."

الأسئلة، والإجابات التي تلقّتها:

| السؤال | الإجابة | | --- | --- | | ماذا يغطي السعر الأساسي؟ | الكيلوغرام الأول — 1 كغ بالضبط ما زال بالسعر الأساسي. | | هل يُحتسب 1.2 كغ على أنه 1 كغ أم 2؟ | كل كيلوغرام بُدئ فيه يُحتسب: 1.2 كغ يُحتسب 2 كغ. | | هل يُقبل 30 كغ نفسه؟ | نعم. 30 مسموح؛ وكل ما فوقه مرفوض. | | وماذا عن 0 كغ، أو وزن سالب؟ | مرفوض — فهذا خطأ في إدخال البيانات. | | كيف تُكتب المناطق؟ | "local" أو "national" بالضبط. وأي شيء آخر مرفوض، بما في ذلك "Local". | | ما الذي يُعاد؟ | التكلفة عدداً صحيحاً. لا يُطبع شيء ولا يُحفظ شيء. |

العقد والدالة القابلة للاختبار، shipping.py. تأخذ كل شيء وسائطَ وتعيد رقماً؛ ولا غلاف يُفصل هنا، لأنه لا إدخال ولا إخراج فيها على الإطلاق:

python
import math

BASE = {"local": 60, "national": 120}       # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30


def shipping_cost(weight_kg, zone):
    """Return the shipping cost of one parcel, as a whole number.

    - weight_kg: a number. At or below 0, or above 30: ValueError.
    - zone: "local" or "national", exactly. Anything else: ValueError.
    - The base price covers the first kilogram (1 kg included).
    - Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
    - No side effects: reads nothing, prints nothing, changes nothing.
    """
    if zone not in BASE:
        raise ValueError(f"unknown zone: {zone!r}")
    if not 0 < weight_kg <= MAX_WEIGHT_KG:
        raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
    extra_kg = math.ceil(weight_kg) - 1
    return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]

تقرّب math.ceil إلى العدد الصحيح التالي صعوداً — math.ceil(1.2) تساوي 2 — وهذا بالضبط معنى "كل كيلوغرام بُدئ فيه يُحتسب".

جدول الحالات. فئات الوزن هي: غير صالح (0 فما دون)، والكيلوغرام الأساسي (فوق 0 حتى 1)، والكيلوغرامات الإضافية (فوق 1 حتى 30)، وغير صالح (فوق 30). والمناطق هي "local" و"national" وأي شيء آخر. وكل قيمة متوقعة محسوبة يدوياً من العقد، لا من الكود:

| الحالة | المدخل | المتوقع | السبب | | --- | --- | --- | --- | | طرد خفيف | (0.5, "local") | 60 | داخل الكيلوغرام الأساسي | | 1 كغ بالضبط | (1, "local") | 60 | الحدّ الأعلى للأساسي: 1 كغ مشمول | | فوق 1 كغ بقليل | (1.2, "local") | 80 | الكيلوغرام الذي بُدئ فيه يُحتسب: 60 + 1 × 20 | | كيلوغرامات كاملة، محلي | (3, "local") | 100 | 60 + 2 × 20 | | كيلوغرامات كاملة، وطني | (3, "national") | 180 | 120 + 2 × 30 — المنطقة الأخرى | | عند الحدّ | (30, "national") | 990 | 120 + 29 × 30؛ و30 مقبول | | صفر | (0, "local") | ValueError | الحدّ الأدنى: 0 مرفوض | | سالب | (-2, "local") | ValueError | مدخل غير صالح | | فوق الحدّ | (30.5, "local") | ValueError | فوق 30 مباشرة | | منطقة خاطئة | (2, "Local") | ValueError | ليست "local" بالضبط |

وما ليس في الجدول عن قصد: وزن قيمته None أو "2". يقول العقد إن الوزن رقم، فهذان خارجه — ترفع بايثون من تلقاء نفسها TypeError حين يُقارَنان بـ 0، ولا نَعِد بشيء أكثر من ذلك. ولا math.ceil كذلك: فهي لبايثون لا لنا. ولا (2, "local") بجوار (3, "local"): الفئة نفسها، والسطر نفسه من الكود، ولا شيء جديد يُلتقط.

عمليات التحقق، check_shipping.py — الجدول صفاً بصف:

python
from shipping import shipping_cost


def raises_value_error(weight_kg, zone):
    """True if shipping_cost raises ValueError for these arguments."""
    try:
        shipping_cost(weight_kg, zone)
    except ValueError:
        return True
    return False


# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990

# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")

print("all 10 checks passed")
text
$ python check_shipping.py
all 10 checks passed

والآن اكسرها. بعد بضعة أشهر، يأتي أحدهم فيرتّب shipping.py، ويحذف import math ويكتب int(weight_kg) بدلاً من math.ceil(weight_kg). تبدو مقروءة، وتعمل دون خطأ. شغّل عمليات التحقق من جديد:

text
$ python check_shipping.py
Traceback (most recent call last):
  File "/home/you/shop/check_shipping.py", line 14, in <module>
    assert shipping_cost(0.5, "local") == 60
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

أدّت الخطة مهمتها: اكتُشف التغيير لحظة تشغيل عمليات التحقق. تقطع int المنازل العشرية بدلاً من التقريب صعوداً، فصار 0.5 كغ يساوي int(0.5) - 1، أي -1 كيلوغراماً إضافياً.

لكن اقرأ التقرير كأنك لا تعرف ذلك مسبقاً، ولاحظ كم هو قليل ما يقوله:

  • ما القيمة الفعلية؟ يُظهر التقرير السطر لا الرقم. عليك أن تشغّل shipping_cost(0.5, "local") بنفسك لتعرف أنها كانت 40.
  • ما الذي انكسر أيضاً؟ توقف السكربت عند أول إخفاق. حالة 1.2 كغ خاطئة أيضاً — فهي تعطي الآن 60 بدلاً من 80 — لكن ذلك التحقق لم يُشغَّل قط.
  • أي الوعود صمدت؟ لا شيء يخبرك بأن حالات الخطأ ما زالت تنجح.
  • كما احتاجت عمليات التحقق من الأخطاء إلى دالة مساعدة فيها try/except لمجرد أن تقول "يجب أن يُرفع خطأ هنا".

التفكير كان الجزء الصعب، وقد أُنجز: العقد، والجدول، والمرجع. ما ينقص هو مشغّل أفضل — مشغّل يعثر على عمليات التحقق بنفسه، ويشغّلها كلها حتى بعد إخفاق، ويعرض القيمة الفعلية بجوار المتوقعة، ويتحقق من الأخطاء في سطر واحد. هذا هو pytest، وتثبيته هو موضوع الفصل القادم.


الأخطاء الشائعة وحلولها

هذه أخطاء في التخطيط لا أخطاء في بايثون، لذا أعراضها أهدأ: في العادة تحقق ينجح وكان ينبغي أن يفشل.

القيمة المتوقعة تكرر صيغة الكود نفسه يُكتب التحقق بإجراء الحساب نفسه الذي تجريه الدالة. هنا في الدالة خلل int من الأعلى، وفي التحقق كذلك:

python
def shipping_cost(weight_kg, zone):
    # Local zone only, to keep the example short. Bug: int() instead of math.ceil().
    return 60 + (int(weight_kg) - 1) * 20


# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")

# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80
text
passed
Traceback (most recent call last):
  File "/home/you/shop/rederive.py", line 11, in <module>
    assert shipping_cost(1.2, "local") == 80
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

وهكذا يبدو أيضاً "اختبار التنفيذ": التحقق يحاكي كيف يعمل الكود بدلاً من أن يصرّح بـما يَعِد به. اكتب القيمة المتوقعة رقماً صريحاً مأخوذاً من العقد.

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

الخطة خالية من الحالات الحدّية كل التحققات تنجح، والخلل قابع على الحدّ: الساعة الخامسة، 1 كغ بالضبط، العمر 12. والسبب المعتاد جدول لا يحوي إلا صفوفاً "معتادة". لكل < و<= و"حتى" و"من" و"على الأقل" في العقد، ينبغي أن تكون هناك حالة على كل جانب.

لا يستطيع التحقق استدعاء الدالة EOFError: EOF when reading a line يعني أن الدالة تنتظر لوحة المفاتيح؛ وreturned: None يعني أنها طبعت إجابتها بدلاً من إعادتها؛ والتحقق الذي ينجح صباحاً ويفشل مساءً يعني أنها تقرأ الساعة. الثلاثة مشكلات تصميم، تُعالج بإعادة التشكيل إلى نواة نقية وغلاف رقيق من القسم الثاني.

SyntaxWarning: assertion is always true, perhaps remove parentheses? الأقواس حول assert ورسالتها تحوّلهما إلى صفّ (tuple) واحد، والصفّ غير الفارغ صحيح دائماً:

python
def drink_price(price, hour):
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")
text
/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
  assert (drink_price(10, 17) == 999, "happy hour price")
passed?!

من الواضح أن 999 خاطئة، ومع ذلك نجح التحقق. اكتب assert drink_price(10, 17) == 999, "happy hour price" — بلا أقواس — فيفشل كما ينبغي. بايثون تحذّرك هنا؛ فاقرأ التحذير بدلاً من أن تتجاوزه.

تحقق يقارن نتائج عشرية بـ == يفشل مع إجابة صحيحة 0.1 + 0.2 == 0.3 قيمتها False في بايثون، لأن 0.1 + 0.2 تساوي 0.30000000000000004. والدرس التخطيطي هنا أن تذكر في العقد كيف تُقرَّب النتائج، كما تفعل member_discount وdrink_price. أما أداة pytest لمقارنة الأعداد العشرية فهي موضوع الفصل الخامس.