تصميم الاختبارات و TDD — ما الذي يجعل الاختبار جيداً
التخطيط للاختبار قبل كتابته، ونمط Arrange-Act-Assert، واختبار السلوك لا طريقة التنفيذ، ودورة red-green-refactor، والاختبار القائم على الخصائص مع Hypothesis، وأسباب الاختبارات المتقلبة وعلاجها، واختبار انحدار لكل خلل.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
أنت تعرف الآن كل أداة وعد هذا المقرر بتعليمها: assert، و raises، و parametrize، و fixtures، و mocks، و markers، والإعدادات، وقياس التغطية (coverage)، والإضافات (plugins). ومن الممكن تماماً أن تستخدمها جميعاً ثم ينتهي بك الأمر باختبارات تضر أكثر مما تنفع.
إليك عربة تسوق صغيرة، مع اختبارين ينجحان:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sثم يأتي شخص ويحسّن العربة. يجب أن تُدمج إضافة المنتج نفسه مرتين في سطر واحد، لذا تتحول القائمة إلى قاموس مفاتيحه أسماء المنتجات. ما زالت العربة تضيف المنتجات وتحسب مجموعها بشكل صحيح — لم يتغير أي شيء يمكن أن يلاحظه العميل:
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sاختبار أحمر، ولا يوجد أي خطأ برمجي. لم يكن الاختبار يتحقق مما تفعله العربة، بل من الطريقة التي بُنيت بها. اختبار كهذا يفشل عندما تحسّن الشيفرة، ويبقى صامتاً عندما تعطلها — وهذا عكس مهمته تماماً.
هذا الفصل الأخير لا يتناول أداة جديدة، بل يتناول حسن التقدير: كيف يبدو الاختبار الجيد، وكيف تدع الاختبارات تقود كتابة الشيفرة، وكيف تدع الحاسوب يبتكر حالات الاختبار نيابة عنك، وكيف تمنع الاختبارات من الفشل بشكل عشوائي.
بنهاية هذا الفصل ستكون قادراً على
- التخطيط للاختبار قبل كتابته: العقد (contract)، والحالات، وما يجب استبعاده
- بناء الاختبار وفق نمط Arrange و Act و Assert، وتسميته بجملة مفهومة
- التمييز بين اختبار السلوك واختبار التنفيذ الداخلي، وإعادة كتابة الثاني ليصبح من النوع الأول
- العمل في دورات أحمر-أخضر-إعادة هيكلة (red-green-refactor)، مع فشل حقيقي في كل خطوة حمراء
- كتابة اختبارات قائمة على الخصائص (property-based) باستخدام Hypothesis، وقراءة مثال فاشل بعد تقليصه
- اكتشاف الأسباب الأربعة المعتادة للاختبارات المتقلبة (flaky) وإصلاحها
- تحويل كل بلاغ عن خطأ إلى اختبار انحدار (regression test)
المتطلبات المسبقة: الاختبارات غير المتزامنة و hooks والإضافات.
قبل أن تكتب الاختبار
معظم الاختبارات السيئة ليست سيئة الكتابة، بل سيئة التخطيط — كُتبت قبل أن يقرر أحد ما الذي تعد به الشيفرة. لذا، قبل كتابة أي سطر من شيفرة الاختبار، اطرح أربعة أسئلة.
على مدار هذا الفصل سنبني دالة صغيرة واحدة بأسلوب الاختبار أولاً: slugify، التي تحول عنوان منشور مثل "Café au lait" إلى جزء الرابط "cafe-au-lait".
1. ما هو العقد؟ صُغه في جملة أو جملتين، كما يراه المستدعي: بالنظر إلى عنوان (من نوع `str`)، أعد slug: أحرف ASCII صغيرة وأرقام، والكلمات مربوطة بشرطات مفردة، ولا شرطة في أي من الطرفين. وتتحول الأحرف ذات العلامات إلى الحرف المجرد المقابل لها. لا توجد آثار جانبية — فهي لا تلمس أي ملف، ولا الساعة، ولا الشبكة. لاحظ ما لا يذكره العقد: التعابير النمطية (regular expressions)، وتوحيد Unicode، والدوال المساعدة. هذه كلها تفاصيل طريقة التنفيذ، ولك حرية تغييرها.
2. ما الذي يجب أن يكون جاهزاً؟ بيئة افتراضية مثبت فيها الأدوات اللازمة، وشيفرة يمكن استيرادها من الاختبارات:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesisثم ملفان جنباً إلى جنب — slugs.py و test_slugs.py — وتشغيل pytest من ذلك المجلد، حتى تعمل العبارة from slugs import slugify. لا يحتاج هذا الموضوع إلى fixtures، ولا ملفات مؤقتة، ولا متغيرات بيئة. وهذا أمر يستحق الملاحظة أيضاً: الدالة النقية (pure function) هي أسهل شيء في العالم لاختباره، وهذا سبب وجيه لدفع المنطق البرمجي إلى دوال نقية.
3. ما هي الحالات؟ المسار السليم أولاً، ثم الحالات الطرفية، ثم المدخلات غير الصالحة، ثم الحدود:
| الحالة | المدخل | المتوقع | |---|---|---| | كلمات عادية | "Hello World" | "hello-world" | | علامات الترقيم | "Hello, World!" | "hello-world" | | أحرف ذات علامات | "Café au lait" | "cafe-au-lait" | | مسافات زائدة في الطرفين | " Hello World " | "hello-world" | | الأرقام تبقى | "Top 10 Tips" | "top-10-tips" | | لا شيء قابل للاستخدام | "!!!" | ? |
علامة الاستفهام هذه هي أكثر خلية مفيدة في الجدول. لقد كشفت كتابة الخطة عن قرار لم يتخذه أحد: ما الذي يجب أن يحدث عندما لا يحتوي العنوان على أي حرف على الإطلاق؟ سنتركه مفتوحاً عن قصد، وسنرى لاحقاً في هذا الفصل كم يكلف سؤال بلا إجابة.
4. ما الذي لن تختبره؟ وحدة re في بايثون، و unicodedata، و str.lower() لها اختباراتها الخاصة بالفعل؛ وإعادة اختبارها لن تفعل سوى إبطائك. أما الدوال المساعدة الخاصة (أي شيء يبدأ بـ _) فيُوصل إليها عبر slugify، ولا تُختبر مباشرة أبداً. ولن تحاول سرد كل محارف Unicode يدوياً — فتلك مهمة الاختبار القائم على الخصائص، لاحقاً في هذا الفصل.
يتحول الجدول إلى اختبارات. وكل ما يلي هو تنفيذ لهذه الخطة.
الترتيب، ثم التنفيذ، ثم التحقق (Arrange, Act, Assert)
كل اختبار جيد يتكون من الأجزاء الثلاثة نفسها، وبالترتيب نفسه:
- Arrange (الترتيب) — ابنِ العالم الذي يحتاجه الاختبار
- Act (التنفيذ) — نفّذ الشيء الواحد الذي تختبره
- Assert (التحقق) — تحقق مما نتج
إليك العربة مختبرة من جديد — هذه المرة من خلال ما تعد به، لا من خلال طريقة تخزينها للأشياء:
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0على العربة المبنية على القائمة، ثم مرة أخرى على العربة المبنية على القاموس:
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01sالاختبارات نفسها، وتنفيذان مختلفان، وكلها خضراء في المرتين. هذه هي الخاصية التي تريدها: إعادة الهيكلة التي تحافظ على السلوك يجب أن تُبقي الاختبارات خضراء. التعليقات في الاختبار الأوسط موجودة فقط لتوضيح البنية؛ ومعظم الناس يحذفونها ويفصلون الأجزاء الثلاثة بأسطر فارغة، كما في الاختبار الثالث.
لماذا يهم أن يكون التنفيذ (Act) خطوة واحدة؟ لأنك عندما يفشل الاختبار، تريد أن تعرف أي شيء تعطل. إذا كان التنفيذ خمسة استدعاءات، فإن الفشل يشير إلى خمسة مشتبه بهم.
اختبر السلوك، لا التنفيذ الداخلي
القاعدة العملية: لا يجوز للاختبار أن يستخدم إلا ما يجوز للمستدعي استخدامه. بالنسبة للعربة، هذا يعني Cart() و .add() و .total(). وليس _items، ولا _lines. الشرطة السفلية في البداية هي طريقة بايثون في قول "هذا ملكي، وقد أغيره".
الطريقة الخاطئة تربط الاختبار بقرار لم يكن يوماً جزءاً من العقد — وهو أن المنتجات تُحفظ في قائمة. أما الطريقة الصحيحة فتطرح السؤال الذي قد يطرحه المستدعي: إذا أضفت هذه الأشياء، فما المجموع؟
علامتان على أن الاختبار مرتبط بالتنفيذ الداخلي:
- يقرأ خصائص تبدأ بـ
_، أو يستخدم mock لدالة تستدعيها الشيفرة داخلياً - إعادة هيكلة لا يمكن لأي مستخدم ملاحظتها تجعله أحمر
تستحق الـ mocks ملاحظة هنا. فالـ mocks التي رأيتها في الفصل الحادي عشر هي الأداة الصحيحة عند الحدود — الشبكة، والساعة، وخدمة الدفع. أما إذا استُخدمت داخل شيفرتك أنت ("تحقق من أن _calculate استُدعيت بهذه الوسائط")، فإنها تصبح اختبارات للتنفيذ الداخلي تحت اسم آخر.
أسماء تُقرأ كجمل، وسبب واحد للفشل
شغّل اختبارات العربة الثلاثة مع -v:
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]اقرأ الأسماء وحدها وستحصل على مواصفات العربة. أما test_add و test_total فلم يخبراك إلا بالدالة التي جرى لمسها. الاسم الجيد يذكر الموقف والنتيجة المتوقعة: العربة الفارغة مجموعها صفر. الاسم طويل؛ ولا بأس بذلك. فأنت لا تكتبه أبداً، لكنك تقرؤه في كل مرة يفشل فيها.
النصف الثاني من القاعدة هو سبب واحد للفشل. إليك العكس — اختبار واحد يتحقق من كل شيء، مُشغّلاً على عربة فيها خطأ (تتجاهل quantity):
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(يخفي --tb=no تتبعات الأخطاء ويترك الملخص فقط — وهو الجزء الذي تقرؤه أولاً.) فشل test_cart — وهذا لا يقول شيئاً — مع 865.0 == 880.0، وعليك الآن أن تحل هذا اللغز. كما أن التحقق الثالث لم يُنفذ أبداً، فلا تعرف إن كان سينجح أم لا. والخطأ نفسه مع الاختبارات الثلاثة المركزة:
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sقبل أن تفتح أي تتبع للأخطاء، يُقرأ الملخص بالفعل كأنه تشخيص: العربة الفارغة سليمة، لكن الكميات تُحسب بشكل خاطئ. "سبب واحد للفشل" لا يعني سطر assert واحداً — فلا بأس بعبارتي assert تتحققان من جانبين للنتيجة نفسها. بل يعني سلوكاً واحداً لكل اختبار.
هرم الاختبارات
تأتي الاختبارات بأحجام مختلفة:
- اختبارات الوحدة (unit tests) تتحقق من دالة أو صنف واحد دون أي شيء حقيقي حوله. كل منها يستغرق أجزاء من الألف من الثانية. ولديك منها الآلاف.
- اختبارات التكامل (integration tests) تتحقق من أن القطع تتلاءم معاً: شيفرتك مع قاعدة بيانات حقيقية، أو نظام ملفات حقيقي، أو تطبيق HTTP حقيقي عبر عميل اختبار. وهي أبطأ؛ ولديك منها العشرات إلى المئات.
- الاختبارات الشاملة (end-to-end) تقود النظام بأكمله كما يفعل المستخدم — متصفح، أو واجهة API منشورة. وهي بطيئة وهشة؛ ولديك منها عدد قليل، يغطي المسارات التي تجلب المال.
إذا رسمتها حسب العدد، تحصل على هرم: قاعدة عريضة من اختبارات الوحدة، وقمة رفيعة من الاختبارات الشاملة. والسبب هو التكلفة. عندما يفشل اختبار وحدة، فإنه يشير إلى بضعة أسطر. وعندما يفشل اختبار شامل، فإنه يشير إلى النظام بأكمله. ادفع كل تحقق إلى أدنى مستوى ممكن في الهرم، واحتفظ بالقمة لما لا يمكن أن تراه إلا القمة.
أحمر، أخضر، إعادة هيكلة
يسير التطوير الموجه بالاختبارات (TDD) في حلقة محكمة:
- أحمر — اكتب اختباراً صغيراً واحداً لسلوك غير موجود بعد، وشاهده يفشل
- أخضر — اكتب أقل قدر من الشيفرة يجعله ينجح
- إعادة هيكلة — رتّب الشيفرة، مع بقاء الاختبارات خضراء طوال الوقت
لماذا تشاهده يفشل؟ لأن الاختبار الذي لم تره يفشل قط ربما لا يختبر شيئاً. تثبت الخطوة الحمراء أن الاختبار قادر على اكتشاف غياب الميزة.
نأخذ الخطة من الجدول، صفاً تلو الآخر.
أحمر. الصف الأول، قبل أن يوجد slugs.py:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sهذا أحمر سليم تماماً. فالاختبار يطلب شيئاً غير موجود.
أخضر. أقل قدر من الشيفرة ينجح — وهو فعلاً الأقل:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sيبدو الأمر كأنه غش. لكنه مقصود: الشيفرة لا تفعل إلا ما طلبه اختبار حتى الآن. وأي شيء إضافي سيكون شيفرة لا يتحقق منها أي اختبار.
أحمر. الصف الثاني من الجدول:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01sيحدد السطر ? بالضبط الحرفين اللذين لا ينبغي وجودهما.
أخضر. الآن لم تعد حيلة استبدال مسافة واحدة كافية. استبدل كل سلسلة من "ما ليس حرفاً أو رقماً" بشرطة واحدة، ثم احذف الشرطات من الطرفين:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sصفا المسافات الزائدة والأرقام سينجحان بالفعل مع هذه الشيفرة. أضفهما على أي حال — فهما جزء من العقد — لكن اعلم أن الاختبار الذي يكون أخضر من أول تشغيل لم يثبت أي شيء عن الشيفرة التي كتبتها للتو. وحده الاختبار الذي كان أحمر ثم أصبح أخضر يثبت ذلك.
أحمر. الأحرف ذات العلامات:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01sالحرف é ليس ضمن a-z، لذا حُذف مع علامات الترقيم.
أخضر. يقسم توحيد Unicode بصيغة "NFKD" الحرف é إلى e مع علامة تشكيل منفصلة؛ ثم يحذف الترميز إلى ASCII مع "ignore" تلك العلامة ويبقي على e:
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sإعادة هيكلة. الشيفرة تعمل، لكن جسم الدالة سطر واحد مكثف. أعطِ كل خطوة اسماً، وصرّف النمط مرة واحدة، وأضف تلميحات الأنواع (type hints) — دون تغيير أي سلوك:
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================هذه هي الخطوة التي تجعل TDD مجدياً. لقد تمكنت من إعادة البناء بحرية لأن الاختبارات تتحقق من السلوك؛ ولو أنها تدخلت في _to_ascii أو في التعبير النمطي، لكانت إعادة الهيكلة قد كسرتها.
الاختبار القائم على الخصائص باستخدام Hypothesis
كل اختبار حتى الآن يعتمد على مثال واحد مبتكر: "Hello World"، و "Café au lait". والأمثلة لا تكون أفضل من خيالك، وخيالك يشترك في نقاطه العمياء مع الشيفرة التي كتبتها للتو.
الخاصية (property) عبارة تصح على كل مدخل. تولّد Hypothesis المدخلات — مئة لكل اختبار افتراضياً — وتبذل جهدها لإيجاد مدخل يكسر تلك العبارة. يحدد @given مصدر المدخلات؛ وتصف strategies (التي تُستورد دائماً باسم st) شكلها:
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sنقطة واحدة، لكن مئة قائمة من الأسعار ومئة خصم مرت عبره. ومن الاستراتيجيات الأخرى التي ستلجأ إليها: st.text()، و st.floats()، و st.booleans()، و st.sampled_from([...])، و st.dictionaries(...)، و st.builds(...).
ما الخصائص التي تملكها slugify؟ لا يمكنك أن تقول ما هو الـ slug لنص عشوائي، لكن يمكنك أن تقول كيف يجب أن يبدو، وأن تطبيق slugify على slug لا يغير شيئاً:
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sينتج st.text() رموزاً تعبيرية (emoji)، ونصوصاً صينية، ومحارف تحكم، ونصوصاً فارغة — مدخلات ما كنت لتكتبها أبداً في جدول الخطة.
الذهاب والإياب يكشف خطأً حقيقياً
أكثر الخصائص إنتاجية هي الذهاب والإياب (round trip): إذا كان بإمكانك ترميز شيء ثم فك ترميزه مجدداً، فإن فك الترميز يجب أن يعيد الأصل. إليك مُرمّز طول التتابع (run-length encoder) — يصبح "aaab" هو "3a1b" — مع اختبارين بالأمثلة ينجحان:
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sالنص "0" يُرمّز إلى "10" — أي "صفر واحد" — فيقرأ فك الترميز 10 على أنه عدد لا يليه أي حرف. أي نص يحتوي على رقم يتلف. كلا الاختبارين بالأمثلة استخدما الأحرف فقط، لذا لم يكن بإمكانهما رؤية ذلك أبداً.
التقليص (Shrinking)
لم تعثر Hypothesis على "0" من أول مرة. بل وجدت نصاً طويلاً وقبيحاً، ثم قلّصته: جربت مدخلات أصغر وأبسط، محتفظة بكل مدخل ما زال يفشل، حتى لم يعد أي مدخل أبسط يفشل. يمكنك مشاهدة ذلك وهو يحدث بتسجيل كل مدخل فاشل:
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(يثبّت @seed الاختيارات العشوائية بحيث يمكن تكرار هذا التشغيل؛ ويمنع database=None مكتبة Hypothesis من إعادة تشغيل فشل محفوظ في ذاكرتها.) الفشل الأول ثلاثة عشر محرفاً من الضجيج يختبئ بينها 9. كنت ستحدق فيه لدقائق. أما المثال المقلّص فيروي القصة كاملة في محرف واحد: الرقم يكسرها.
تحفظ Hypothesis أيضاً الأمثلة الفاشلة في مجلد .hypothesis/، لذا يجرب التشغيل التالي "0" أولاً. أضف هذا المجلد إلى .gitignore.
يضع الإصلاح فاصلاً بين العدد والحرف، بحيث لا يمكن أبداً الخلط بين رقم وجزء من العدد. ويُثبّت المثال الذي وجدته Hypothesis باستخدام @example، ليُشغّل في كل مرة، وعلى كل جهاز:
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)يتغير الاختباران بالأمثلة إلى الصيغة الجديدة ("3:a1:b")، وتكتسب الخاصية سطراً واحداً:
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sالخصائص لا تحل محل الأمثلة. فالاختباران بالأمثلة يوثقان الصيغة بطريقة يفهمها القارئ من نظرة واحدة؛ والخاصية تحرس الزوايا التي لم يفكر فيها أحد. استخدم الاثنين معاً.
الاختبارات المتقلبة (Flaky tests)
الاختبار المتقلب (flaky) ينجح ويفشل على الشيفرة نفسها. وهو أسوأ من عدم وجود اختبار: إذ يتعلم الناس الضغط على "إعادة التشغيل" ويكفّون عن تصديق اللون الأحمر. يأتي كل اختبار متقلب تقريباً من أحد أربعة أسباب، ولكل منها إصلاح يزيل السبب بدلاً من إخفائه.
الحالة المشتركة وترتيب الاختبارات
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sيفشل ضمن المجموعة، وينجح وحده. المجموعة (set) المعرفة على مستوى الوحدة تبقى من اختبار إلى الذي يليه، فيرى الاختبار الثاني ما تركه الأول. غيّر الترتيب، أو شغّل الاختبارات بالتوازي باستخدام pytest-xdist، وستتغير النتيجة. الإصلاح الخاطئ هو إعادة ترتيب الاختبارات. والإصلاح الصحيح يزيل الحالة المشتركة: اجعل السجل كائناً، وأعط كل اختبار نسخة جديدة عبر fixture:
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sعندما لا تستطيع تغيير الشيفرة، فإن البديل هو fixture من نوع autouse يعيد ضبط الحالة قبل كل اختبار وبعده. يجب أن ينجح كل اختبار وحده، وبأي ترتيب.
الوقت
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonالشيفرة نفسها، مُشغّلة في اللحظة نفسها، على جهازين مضبوطين على منطقتين زمنيتين مختلفتين:
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01sالإصلاح موجود بالفعل في توقيع الدالة: يمكن تمرير now إليها. الاختبار الذي يتحكم في الساعة يختبر جانبي الحد، ويعطي الإجابة نفسها في أي ساعة:
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01sتمرير الوقت كوسيط أبسط من أي mock. وعندما لا تستطيع تغيير التوقيع، فإن monkeypatch من الفصل العاشر يستبدل الساعة بدلاً من ذلك.
العشوائية
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"ست مرات تشغيل، دون تغيير أي شيء:
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01sإصلاحان، لنوعين من الأسئلة. تحقق مما يصح على كل نتيجة — الفائز هو أحد المشاركين. أو تحكم في العشوائية بتمرير مولّد ذي بذرة (seed) ثابتة:
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01sالعالم الخارجي
السبب الرابع هو أي شيء لا تتحكم فيه: استدعاء شبكة حقيقي، أو خادم حقيقي، أو sleep(0.1) "يُفترض أن يكون كافياً". والإصلاحات هي تلك التي رأيتها في الفصول السابقة — استخدم mock عند الحدود، واستخدم tmp_path بدلاً من مجلد مشترك، وانتظر تحقق شرط بدلاً من مدة ثابتة. وهناك قاعدة واحدة تغطي الأسباب الأربعة كلها: كل ما يعتمد عليه الاختبار، يجب أن ينشئه الاختبار أو يتحكم فيه.
اختبار انحدار لكل خطأ
لنعد إلى علامة الاستفهام في جدول الخطة. لم يجب عنها أحد، ثم يصل بلاغ الخطأ: منشور بعنوان `"!!!"` حُفظ في `/posts/`، فاستبدل صفحة الفهرس.
قبل أن تلمس slugify، اكتب اختباراً يعيد إنتاج البلاغ وشاهده يفشل. هذا يثبت أن الاختبار يكتشف هذا الخطأ تحديداً:
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sثم أصلحه — تصبح نهاية slugify كما يلي:
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sينجح اختبار الانحدار والاختبارات الثلاثة بالأمثلة. لكن شغّل المجموعة كاملة، وستعترض اختبارات الخصائص:
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sجربت Hypothesis النص الفارغ، و slugify("") يرفع الآن استثناءً. هذا ليس خطأً جديداً — بل هو العقد يتغير عن قصد، والخصائص تلاحظ ذلك. هذا جيد. حدّثها لتعبر عن العقد الجديد: العناوين التي تحتوي على حرف أو رقم واحد على الأقل.
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)يتحول test_slugifying_a_slug_changes_nothing إلى @given(titles) بالطريقة نفسها.
...... [100%]
6 passed in 0.01sلماذا اختبار لكل خطأ؟ لأن الخطأ الذي وقع مرة أثبت أنه سهل الارتكاب، ومن المرجح أن يرتكبه مجدداً الشخص التالي الذي يلمس تلك الشيفرة. والتعليق الذي يذكر ما الذي حدث خطأً جزء من الاختبار: فبعد عام، سيكون السجل الوحيد الذي يشرح سبب وجود هذه الحالة الغريبة المظهر هنا.
مثال تطبيقي متكامل
أداة لفحص قوة كلمات المرور، مبنية بكل ما في هذا الفصل. الخطة أولاً:
| الحالة | المدخل | المتوقع | |---|---|---| | فارغة | "" | weak | | كل الأنواع، لكنها قصيرة جداً | "Ab1!xyz" (7) | weak | | طويلة بما يكفي، نوع واحد | "abcdefgh" | weak | | طويلة بما يكفي، نوعان | "abcdefg1" | medium | | بطول 12، ثلاثة أنواع | "abcdefghij1!" | strong | | بطول 12، نوعان | "abcdefghijk1" | medium |
passwords.py:
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================ثلاثة قرارات تستحق الملاحظة.
صفوف الجدول تقع على الحدود — 7 و 8 محارف، و 11 و 12، ونوعان وثلاثة. الأخطاء تعيش عند الحدود: فعلامة < التي كان يجب أن تكون <= لا تُرى في منتصف النطاق.
تقول الخاصية شيئاً لا يستطيع أي مثال قوله: إضافة محارف لا تجعل كلمة المرور أضعف أبداً. لن يكتب أحد هذا الاختبار يدوياً لكل كلمة مرور، وتتحقق منه Hypothesis على المئات منها، بما في ذلك أحرف Unicode التي ليست كبيرة ولا صغيرة.
ولا يلمس أي اختبار _kinds. فهي يُوصل إليها عبر strength؛ وقد تختفي غداً.
الأخطاء الشائعة وحلولها
خطأ AttributeError: 'Cart' object has no attribute '_items' بعد إعادة الهيكلة كان الاختبار يقرأ ما بداخل الكائن. أعد كتابته ليتحقق عبر الدوال العامة — أي ما يمكن للمستدعي رؤيته — وسيصمد أمام إعادة الهيكلة التالية أيضاً.
اختبار ينجح وحده لكنه يفشل في التشغيل الكامل (أو العكس) حالة مشتركة: قائمة أو قاموس أو ذاكرة تخزين مؤقت (cache) على مستوى الوحدة؛ أو ملف في موقع ثابت؛ أو متغير بيئة ضُبط ولم يُلغَ قط. اجعل كل اختبار ينشئ ما يحتاجه (fixtures، و tmp_path، و monkeypatch) بدلاً من إعادة ترتيب الاختبارات.
خطأ hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. هناك .filter() (أو assume()) يتخلص من كل ما تولده Hypothesis تقريباً — على سبيل المثال st.integers().filter(lambda n: n % 1000 == 7). ابنِ القيم التي تريدها مباشرة بدلاً من ذلك: st.integers().map(lambda n: n * 1000 + 7).
خطأ hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one أعطى الاختبار إجابة مختلفة للمدخل نفسه. شيء ما خارج الوسائط المولّدة تغير بين الاستدعاءات — عداد، أو قائمة على مستوى الوحدة، أو الساعة. تعيد Hypothesis تشغيل كل فشل، لذا لا يمكن تقليص اختبار ذي حالة خفية.
خطأ hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. يجب أن ينتهي كل مثال مولّد خلال 200 ms افتراضياً، لأن مئة مثال بطيء تصنع مجموعة اختبارات بطيئة. اجعل الاختبار أسرع، أو إذا كان البطء حقيقياً، فارفع المهلة باستخدام @settings(deadline=...).
اختبار TDD جديد ينجح من أول تشغيل إنه لا يثبت أي شيء عن الشيفرة التي توشك على كتابتها. فإما أن السلوك موجود بالفعل (لا بأس — احتفظ بالاختبار كتوثيق)، أو أن الاختبار لا يتحقق مما تظن. اكسر الشيفرة عمداً للحظة وتأكد من أن الاختبار يصبح أحمر.
Step 4 of 6 — Predict
Check your understanding
عند تشغيل الملف كاملاً يفشل الاختبار الثاني. ما السطر الأخير عند تشغيل الاختبار الثاني وحده؟
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed in 0.01s
- B1 passed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 error in 0.01s
استُبدل القاموس بالقائمة داخل Cart مع بقاء السلوك نفسه. أي اختبار سيتحول إلى الأحمر؟
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_total_of_two_pens`
- B`test_empty_cart_totals_zero`
- C`test_one_line_per_item`
- D`test_adding_twice_adds_up`
ينجح هذا الاختبار من الاثنين إلى الجمعة ويفشل يومي السبت والأحد. ما أفضل إصلاح؟
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- Aإعادة تشغيل الاختبار تلقائياً كلما فشل
- Bتخطي الاختبار يومي السبت والأحد
- Cكتابة `== False` بدلاً من `is False`
- Dتمرير تاريخ ثابت — `is_weekend(date(2026, 10, 7))` — وإضافة اختبار منفصل ليوم سبت
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.
دورك الآن
ابنِ دالتين، بأسلوب الاختبار أولاً، في durations.py:
- تحول
parse_duration(text)النص"1h30m"إلى5400(ثانية). الوحدات هيhوmوs، بهذا الترتيب، وكل منها اختيارية؛ فالنصوص"45s"و"2h"و"1h0m5s"كلها صالحة. وأي شيء آخر —""، و"h"، و"1x"، و"30m1h"، و"1.5h"— يرفعValueErrorمع عبارةnot a durationفي الرسالة. - تعمل
format_duration(seconds)في الاتجاه المعاكس: يصبح5400هو"1h30m"، ويصبح3605هو"1h5s"، ويصبح0هو"0s". والعدد السالب يرفعValueError.
اعمل بهذا الترتيب:
- اكتب جدول الخطة أولاً: الحالة، والمدخل، والمتوقع — بما في ذلك المدخلات غير الصالحة والحدود (
59و60ثانية، على سبيل المثال). - تقدم صفاً تلو الآخر في دورات أحمر-أخضر. شغّل
pytestبعد كل تغيير وانظر إلى الأحمر قبل أن تجعله أخضر. - أضف خاصية الذهاب والإياب باستخدام Hypothesis: لأي عدد صحيح من الثواني بدءاً من
0فما فوق،parse_duration(format_duration(n)) == n. - يبلّغ مستخدم أن
"90m"رُفض، رغم أن الناس يكتبونه طوال الوقت. أضف اختبار انحدار مع تعليق يذكر ما الذي حدث خطأً.
عندما تنتهي، أعد الهيكلة مرة واحدة، وتأكد من أن المجموعة كاملة تبقى خضراء.
الحل
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sلماذا بُني الحل بهذه الطريقة:
- أصبحت الخطة جداول
parametrize. كل صف في الجدول هو صف واحد فيparametrize، لذا فإن إضافة حالة لاحقاً تتطلب سطراً واحداً، وتُظهر أسماء الاختبارات في مخرجات-vأي صف فشل. - المدخلات الصالحة وغير الصالحة في اختبارات منفصلة. فهي سلوكيات منفصلة لها أسباب فشل منفصلة. وتحتوي قائمة المدخلات غير الصالحة على مثال واحد لكل نوع من الأخطاء: نص فارغ، ووحدة بلا عدد، ووحدة غير معروفة، وترتيب خاطئ، وعدد عشري.
- يتحقق
match="not a duration"من الرسالة إلى جانب النوع، بحيث لا يستطيعValueErrorيُرفع عن طريق الخطأ في مكان آخر —int("")مثلاً — أن يجعل الاختبار ينجح. - الشرط
if not textموجود لأن النمط، بكل أجزائه الاختيارية، يطابق النص الفارغ. وهذا بالضبط نوع الحالات التي يجبرك جدول الخطة على التفكير فيها قبل أن يفاجئك بها التعبير النمطي. 59و60هما الحد الذي تتحول عنده الثواني إلى دقائق؛ والاختبار في منتصف النطاق لن يكتشف أبداً خطأ الفارق بواحد (off-by-one) هناك.- خاصية الذهاب والإياب هي أقوى اختبار في الملف. فهي لا تعرف قيمة متوقعة واحدة، ومع ذلك تقارن عشرة ملايين مدة ممكنة بين الدالتين — وأي اختلاف بينهما يظهر، مقلّصاً إلى أصغر عدد يكسرهما.
- اختبار الانحدار ينجح بالفعل مع هذا التنفيذ؛ ووجوده لمنع أي "ترتيب" مستقبلي يقصر الدقائق على
0-59من إعادة الخطأ. ويذكر تعليقه السبب. - لا شيء يختبر
PATTERNأوUNITSمباشرة. فهما من تفاصيل التنفيذ، ولك حرية استبدال التعبير النمطي بمحلل مكتوب يدوياً غداً.
إلى أين بعد ذلك
هذه نهاية المقرر. أصبحت قادراً على كتابة الاختبارات، وقراءة حالات فشلها، وتنظيمها، وعزلها، وقياسها، وتوسيع pytest نفسه، و — بعد هذا الفصل — تحديد ما يستحق الاختبار من الأساس. إليك بعض الاتجاهات من هنا:
- اجعله عادة على شيفرة حقيقية. خذ مشروعاً لديك بالفعل وأضف اختبارات للجزء الذي تخشى تغييره أكثر من غيره. اكتب جدول الخطة أولاً. يتراجع الخوف كلما ازدادت الاختبارات.
- تعمّق أكثر في Hypothesis. يغطي توثيقها الاختبار ذا الحالة (stateful testing) عبر
RuleBasedStateMachine، الذي يولّد تسلسلات كاملة من العمليات على كائن ما، ويجد أخطاء لا يمكن لأي مدخل منفرد أن يكشفها. - اختبار الطفرات (Mutation testing). تُجري أدوات مثل
mutmutتغييرات صغيرة على شيفرتك —<إلى<=، و+إلى-— وتتحقق من أن اختباراً ما يفشل. والطفرة التي تنجو تمثل سطراً لا تتحقق منه اختباراتك فعلاً، مهما كانت نسبة التغطية مرتفعة. - طوّر إعداد CI من الفصل الرابع عشر. شغّل المجموعة على كل إصدار من بايثون تدعمه، واجعل الاختبار الفاشل يمنع الدمج — فمجموعة الاختبارات التي لا يُجبر أحد على إبقائها خضراء تتوقف تدريجياً عن كونها خضراء.
- اقرأ مجموعات اختبارات جيدة. اختبارات
pytestوrequestsوattrsوhypothesisنفسها مفتوحة المصدر. وقراءة الطريقة التي يسمي بها أصحاب الخبرة اختباراتهم وينظمونها ويعزلونها تعلّمك أكثر من أي قائمة قواعد.
مهما كان ما ستكتبه بعد ذلك، ابدأ بالسؤال الذي افتُتح به هذا الفصل: إذا حسّن أحدهم هذه الشيفرة غداً دون تغيير ما تفعله، هل ستبقى اختباراتي خضراء — وإذا كسرها أحدهم، هل ستصبح حمراء؟
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz