قبل أن تكتب اختباراً — ما الذي تحتاجه فعلاً
التفكير الذي يسبق أي كود اختبار: تحويل متطلب غامض إلى عقد دقيق، وجعل الكود قابلاً للاختبار، واختيار الحالات مع الحدود، ومعرفة الإجابة المتوقعة دون تشغيل الكود، وتحديد ما لا يُختبر — كل ذلك ببايثون العادية و assert.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك دالة تحسب متوسط قائمة من درجات الامتحان، والطريقة التي يتحقق بها معظمنا من دالة كهذه — نستدعيها مرتين وننظر:
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0الإجابتان صحيحتان. الدالة "تعمل"، فتدخل البرنامج. وبعد أسبوع يصل إلى السطر نفسه فصلٌ لم تُرصد له أي نتائج بعد:
def average(scores):
return sum(scores) / len(scores)
print(average([]))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 بعد؛ فكل ما في هذا الفصل يعمل ببايثون العادية.
قبل أن تكتب الاختبار
في كل فصل من فصول هذه الدورة قسم بهذا الاسم، وكلها تجيب عن الأسئلة الستة نفسها، كلٌّ في موضوعه. وهذا الفصل هو منبع تلك الأسئلة، فإليك إياها في مكان واحد. احتفظ بها؛ فبقية الدورة تستخدمها قائمةَ تحقق.
- ما الذي يُوعَد به بالضبط؟ العقد: لهذه المدخلات هذا المخرج؛ ولهذه المدخلات هذا الخطأ؛ وهذه الآثار الجانبية، أو لا شيء منها.
- هل يمكن لاختبار أن يستدعي الكود؟ هل يأخذ مدخلاته وسائطَ ويعيد نتيجة — أم يقرأ من لوحة المفاتيح ويطبع وينظر إلى الساعة؟
- أي الحالات؟ حالة من كل نوع من المدخلات، وجانبا كل حدّ، والمدخلات غير الصالحة، والفارغ والكبير جداً.
- كيف أعرف الإجابة الصحيحة؟ بالحساب اليدوي أو من المواصفات — لا بتشغيل الكود قيد الاختبار أبداً.
- ما الذي يجب أن يكون جاهزاً؟ إصدار بايثون، وبيئة، ومكان ملفات الاختبار — وفي الفصول اللاحقة الملفات والإعدادات والأدوات التي يحتاجها الموضوع.
- ما الذي لن أختبره؟ بايثون نفسها، ومكتبات الآخرين، والكود الأبسط من أن يخطئ، والسلوك الذي لم يَعِد به أحد.
وإليك تطبيقها على 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 تتبع الآن العقد الذي حُسم:
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")3 claims checkedلاحظ ما يحتاجه كل ادعاء: مدخلاً تختاره أنت، ونتيجة تعرفها مسبقاً، وطريقة لاستدعاء الكود والنظر فيما فعله. الأسئلة الستة موجودة لتضمن أن الثلاثة لديك قبل أن تبدأ. ولاحظ كذلك كم هو ثقيل التحقق من الخطأ — خمسة أسطر من try/except/else لتقول "يجب أن يُرفع خطأ هنا". يحوّل pytest ذلك إلى سطر واحد في الفصل الرابع.
1. العقد: من متطلب غامض إلى وعد دقيق
تصل المتطلبات عادةً على شكل جملة: "يحصل الأعضاء على خصم 10% على الطلبات الكبيرة." تبدو كاملة. أعطها لمطوّرَين حريصَين وراقب:
# "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))500 0 0
1000 0 100
1234.56 123.456 123لم يخطئ أيٌّ منهما. الجملة لم تقل هل يُعدّ 1000 بالضبط طلباً "كبيراً"، ولا كيف يكون التقريب. ملأ كلٌّ منهما الثغرة بطريقته، فيحصل طلب قيمته 1000 على خصم 0 من أحدهما و100 من الآخر. لا يستطيع أي اختبار أن يقول أيهما على صواب، لأن "الصواب" لم يُعرَّف قط.
العقد هو المتطلب وقد صار دقيقاً بما يكفي ليُختبر. وللوصول من الجملة إلى العقد، اطرح أسئلة — الأسئلة نفسها في كل مرة:
- المدخلات. ما أنواعها؟ ما نطاقها؟ هل هناك حدّ، وهل الحدّ نفسه مشمول؟ وماذا عن الصفر والسالب والفارغ و
None؟ - المخرج. ما الذي يُعاد بالضبط — الخصم أم الإجمالي الجديد؟ ما نوعه؟ ويُقرَّب إلى ماذا؟
- الأخطاء. أي المدخلات تُرفض، وكيف — بأي استثناء؟
- الآثار الجانبية. هل يغيّر شيئاً — قائمة مُمرَّرة إليه، أو ملفاً، أو قاعدة بيانات — أو يطبع شيئاً؟ أم لا شيء على الإطلاق؟
حين تُطرح هذه الأسئلة عن الخصم تنتج إجابات، والإجابات توضع حيث يوجد الكود — في سلسلة التوثيق (docstring):
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)0
100.0
123.46
0
ValueError: total must not be negative, got -5كل سطر في سلسلة التوثيق صار الآن شيئاً يستطيع الاختبار التحقق منه، ولكل تحقق قيمة متوقعة واضحة. وهذا هو اختبار العقد الجيد: هل يستطيع شخص غريب أن يكتب القيمة المتوقعة لأي مدخل دون أن يقرأ الكود؟ إن لم يستطع، فما زال هناك سؤال ينبغي طرحه.
من يجيب عن الأسئلة؟ صاحب المتطلب أياً كان — مالك المنتج، أو معلّم، أو عميل، أو أنت. وحين لا يوجد من تسأله، قرّر، واكتب القرار في العقد، وامضِ قُدماً. القرار الصريح يمكن مراجعته وتغييره؛ أما القرار الصامت فيبقى في الكود إلى أن يفاجئ أحدهم.
2. كود قابل للاختبار: نواة نقية وغلاف رقيق
يستدعي الاختبار دالةً بمدخلات مختارة ويقارن ما يعود منها. بعض الدوال تجعل ذلك مستحيلاً. إليك واحدة لساعة التخفيضات في مقهى — خصم 20% على المشروبات بين 17:00 و19:00:
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:
from drinks import drink_price
result = drink_price()
print("returned:", result)التحقق الآلي يعمل ولا أحد أمام لوحة المفاتيح. و< /dev/null يمنح البرنامج ذلك تماماً — مدخلاً لا شيء فيه:
$ 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أعطها سعراً فتعمل — لكن انظر إلى ما يعود:
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: Noneثلاث مشكلات منفصلة، وكل واحدة منها شائعة:
- تقرأ مدخلها باستخدام
input()بدلاً من أن تأخذه وسيطاً، فلا يستطيع الاختبار أن يختار المدخل. - تطبع نتيجتها بدلاً من أن تعيدها، فيحصل الاختبار على
Noneولا يجد ما يقارنه. - تقرأ الساعة بنفسها. ذلك التشغيل جرى قبيل السابعة صباحاً، فكانت الإجابة
10.00؛ والأمر نفسه في الخامسة والنصف مساءً يعطي8.00. والتحقق منها سينجح أو يفشل بحسب وقت تشغيله — أي اختبار متقلّب (flaky)، وهو أسوأ من عدم وجود اختبار، لأن الناس يتعلمون تجاهله.
الحل ليس حيلة اختبارية، بل تغيير في الشكل: افصل القرار عن الإدخال والإخراج. يصبح القرار دالة نقية — كل ما تحتاجه يدخل وسائطَ، والإجابة تخرج قيمةً معادة، ولا تلمس شيئاً آخر. أما لوحة المفاتيح والشاشة والساعة فتنتقل إلى غلاف رقيق حولها:
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()والآن تختار عمليات التحقق أي ساعة تشاء، في أي وقت من اليوم:
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")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. عندها تنقسم الساعات إلى خمس فئات:
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:
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")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 | مدخل غير صالح |
وهذه الخطة وقد تحولت إلى عمليات تحقق، على الدالة بعد تشديد عقدها:
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")11 checks passedأحد عشر صفاً، ولكلٍّ منها سبب تستطيع أن تقوله بصوت عالٍ.
4. المرجع: معرفة الإجابة دون الكود
كل تحقق يقارن إجابة الكود بإجابة متوقعة. ومصدر الإجابة المتوقعة يسمى المرجع (oracle)، وله قاعدة واحدة: يجب ألا يكون هو الكود قيد الاختبار.
يبدو هذا أوضح من أن يُقال، إلى أن ترى مدى سهولة كسره. هنا في member_discount خلل — 1% بدلاً من 10% — ومعها تحققان:
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")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.
- طريقة مختلفة موثوقة. حساب أبسط وأبطأ، أو دالة من المكتبة القياسية تؤدي العمل نفسه بطريقة أخرى:
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")agrees with statistics.mean on 4 samplesكتب statistics.mean أشخاص آخرون، بطريقة مختلفة، فالاتفاق معها يعني شيئاً. (مقارنة النتائج العشرية بـ == قد تلدغك — 0.1 + 0.2 == 0.3 قيمتها False في بايثون. القيم هنا آمنة؛ والفصل الخامس يبيّن كيف تقارن الأعداد العشرية كما ينبغي.)
إذا لم تستطع استنتاج الإجابة المتوقعة بأي من هذه الوسائل، فليست تلك مشكلة اختبار. إنها تعني أنك لا تعرف بعد ما ينبغي أن يفعله الكود — فعُد إلى العقد.
5. ما الذي يجب أن يكون جاهزاً
في هذا الفصل، Python 3 فقط. تحقق من النسخة التي لديك:
$ 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. تأخذ كل شيء وسائطَ وتعيد رقماً؛ ولا غلاف يُفصل هنا، لأنه لا إدخال ولا إخراج فيها على الإطلاق:
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 — الجدول صفاً بصف:
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")$ python check_shipping.py
all 10 checks passedوالآن اكسرها. بعد بضعة أشهر، يأتي أحدهم فيرتّب shipping.py، ويحذف import math ويكتب int(weight_kg) بدلاً من math.ceil(weight_kg). تبدو مقروءة، وتعمل دون خطأ. شغّل عمليات التحقق من جديد:
$ 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 من الأعلى، وفي التحقق كذلك:
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") == 80passed
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) واحد، والصفّ غير الفارغ صحيح دائماً:
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?!")/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 لمقارنة الأعداد العشرية فهي موضوع الفصل الخامس.
Step 4 of 6 — Predict
Check your understanding
المتطلب: "الطلبات التي قيمتها 500 أو أكثر تُشحن مجاناً." ماذا يحدث عند تشغيل هذا السكربت؟
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- Aتُطبع `checks passed` ثم `all done`
- Bتُطبع `checks passed` ثم يُرفع `AssertionError` عند آخر assert
- Cيُرفع `AssertionError` عند أول assert ولا يُطبع شيء
- Dلا يحدث شيء — تُتجاهل عبارات assert في السكربت
أي دالة يمكن اختبارها بشكل موثوق كما هي، بسطر assert واحد؟
- Adef greet(): print('Hi', input())
- Bdef is_weekend(): return datetime.now().weekday() >= 5
- Cdef is_weekend(weekday): return weekday >= 5
- Ddef log_visit(): VISITS.append(1)
الغرض من هذا الفحص حماية القاعدة "يُحتسب 1.2 كغ على أنه 2 كغ". ما الخطأ فيه؟
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- Aالقيمة المتوقعة مأخوذة من الكود قيد الاختبار نفسه، فلا يمكن للفحص أن يفشل أبداً
- B`1.2` ليست قيمة حدية، فلا فائدة من اختبارها
- Cيجب استخدام `is` بدلاً من `==`
- Dيجب استدعاء الدالة داخل `try`/`except`
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.
دورك الآن
ترسل إليك سينما هذا المتطلب:
"الأطفال يدفعون أقل، والمتقاعدون يحصلون على خصم، والرضّع مجاناً. البالغون يدفعون السعر الكامل، 300."
نفّذ كل ما في هذا الفصل، بالترتيب، قبل أن تكتب تحققاً واحداً:
- اطرح الأسئلة. دوّن خمسة أشياء على الأقل تتركها الجملة مفتوحة — ما المقصود بـ"الأطفال"، ومن أين يبدأ "المتقاعد"، وهل الحدود مشمولة، وما الأسعار، وماذا يحدث مع عمر مستحيل. ثم قرّر الإجابات كما كانت السينما ستقرّرها: من هم دون 3 سنوات مجاناً؛ ومن 3 إلى 12 يدفعون 150؛ ومن 13 إلى 64 يدفعون 300؛ ومن 65 فما فوق يدفعون 200؛ والأعمار من 0 إلى 120، وأي شيء خارجها يرفع
ValueError. - اكتب العقد سلسلةَ توثيق لدالة
ticket_price(age)فيcinema.py. - اجعلها قابلة للاختبار: وسائط تدخل، وقيمة معادة تخرج، بلا
input()وبلاprint(). - اكتب جدول الحالات: الحالة، والمدخل، والمتوقع، والسبب. حدّد فئات التكافؤ أولاً، ثم ضع حالة على كل جانب من كل حدّ. واحسب كل قيمة متوقعة من العقد.
- قرّر ما لن تختبره، وبيّن السبب.
- اكتب
check_cinema.pyبتعليمةassertمجردة واحدة لكل صف، وشغّله.
ثم اكسر الدالة عمداً: غيّر قاعدة الأطفال من "12 فما دون" إلى "دون 12" — <= إلى <. قبل التشغيل، توقّع أي تحقق سيفشل. شغّله وقارن.
الحل
1. الأسئلة، والإجابات التي حُسمت:
| السؤال | الإجابة | | --- | --- | | حتى أي عمر يكون الرضيع مجاناً؟ | الأعمار 0 و1 و2. ومن 3 تصبح التذكرة بمقابل. | | من هو "الطفل"؟ | الأعمار من 3 إلى 12، والطرفان مشمولان. السعر 150. | | هل ابن الثالثة عشرة طفل؟ | لا — عمر 13 يدفع سعر البالغين، 300. | | من أين يبدأ المتقاعدون؟ وهل 65 نفسه مشمول؟ | من 65 فما فوق. السعر 200. | | ما العمر المستحيل؟ | ما دون 0 أو فوق 120: ValueError. | | ما الذي يُعاد؟ | السعر عدداً صحيحاً؛ ولا يُطبع شيء. |
2 و3. العقد والدالة، cinema.py. الحدود ثوابت مسمّاة، فيُقرأ الكود كما يُقرأ الجدول:
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. جدول الحالات. الفئات هي: غير صالح (دون 0)، ومجاني (0–2)، وطفل (3–12)، وبالغ (13–64)، ومتقاعد (65–120)، وغير صالح (فوق 120). وهذا يعطي أربع حالات معتادة، وثماني حالات حدّية، وحالتين غير صالحتين:
| الحالة | المدخل | المتوقع | السبب | | --- | --- | --- | --- | | رضيع معتاد | 1 | 0 | الفئة المجانية | | طفل معتاد | 8 | 150 | فئة الأطفال | | بالغ معتاد | 30 | 300 | فئة البالغين | | متقاعد معتاد | 80 | 200 | فئة المتقاعدين | | مولود جديد | 0 | 0 | أدنى عمر صالح | | آخر عمر مجاني | 2 | 0 | تحت حدّ المجاني/الطفل | | أول عمر طفل | 3 | 150 | على حدّ المجاني/الطفل | | آخر عمر طفل | 12 | 150 | "12 فما دون" — الحدّ نفسه | | أول عمر بالغ | 13 | 300 | بعد حدّ الطفل مباشرة | | آخر عمر بالغ | 64 | 300 | تحت حدّ المتقاعدين | | أول عمر متقاعد | 65 | 200 | "65 فما فوق" — الحدّ نفسه | | أكبر عمر صالح | 120 | 200 | أعلى النطاق الصالح | | دون النطاق | -1 | ValueError | خارجه مباشرة، من الجهة الدنيا | | فوق النطاق | 121 | ValueError | خارجه مباشرة، من الجهة العليا |
5. ما لم يُختبر: نص أو None بدلاً من العمر — العقد يقول عدداً صحيحاً، ولا يَعِد بشيء آخر. وعمر عشري مثل 12.5 — الأمر نفسه. وأسعار سينمات أخرى، أو <= في بايثون — ليس لنا أن نختبرها. وحالة معتادة ثانية في الفئة نفسها، مثل 9 بجوار 8 — لن تلتقط شيئاً جديداً.
6. عمليات التحقق، check_cinema.py:
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passedالخلل المتعمَّد: يصبح if age <= CHILD_UP_TO: هو if age < CHILD_UP_TO:. وحده العمر 12 يغيّر فئته — فيسقط إلى سعر البالغين — لذا يُفترض ألا يفشل إلا تحقق 12:
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorالسطر 23 هو assert ticket_price(12) == 150، كما كان متوقعاً.
لماذا أُنجز الحل بهذه الطريقة:
- الأسئلة جاءت أولاً. "الأطفال" و"المتقاعدون" ليسوا أرقاماً؛ وكل إجابة في الجدول الأول حوّلت كلمة إلى حدّ يمكن اختباره. ولولاها لكان
12و65مجرد تخمين. - الدالة نقية. يدخل العمر ويخرج السعر، ولا شيء غير ذلك — فكل تحقق سطر واحد، ويعطي الإجابة نفسها في أي وقت من اليوم وعلى أي جهاز.
- ثوابت مسمّاة للحدود.
CHILD_UP_TO = 12تقول في مكان واحد ما يقوله العقد، والتحققات تذكر الأرقام نفسها. - صفوف الحدود هي التي أدّت العمل. الأعمار المعتادة الأربعة — 1 و8 و30 و80 — كلها ما زالت تنجح مع وجود الخلل. وحده صف
12، الحدّ نفسه، التقطه. وهذه هي حجة القيم الحدّية كاملة في تشغيل واحد. - كل قيمة متوقعة جاءت من العقد. لم تُنسخ أيٌّ منها من تشغيل
ticket_price، فلا يمكن لإجابة خاطئة من الكود أن تتسلل إلى التحققات. - توقف عند أول إخفاق، ولم يخبرك إلا بالسطر. عرفت أي تحقق فشل لأنك توقعته — لكنه لم يخبرك بأن السعر الفعلي للعمر 12 كان 300. وهذه هي الفجوة التي يسدّها الفصل القادم.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz