الفصل 16

تصميم الاختبارات و TDD — ما الذي يجعل الاختبار جيداً

التخطيط للاختبار قبل كتابته، ونمط Arrange-Act-Assert، واختبار السلوك لا طريقة التنفيذ، ودورة red-green-refactor، والاختبار القائم على الخصائص مع Hypothesis، وأسباب الاختبارات المتقلبة وعلاجها، واختبار انحدار لكل خلل.

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

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

أنت تعرف الآن كل أداة وعد هذا المقرر بتعليمها: assert، و raises، و parametrize، و fixtures، و mocks، و markers، والإعدادات، وقياس التغطية (coverage)، والإضافات (plugins). ومن الممكن تماماً أن تستخدمها جميعاً ثم ينتهي بك الأمر باختبارات تضر أكثر مما تنفع.

إليك عربة تسوق صغيرة، مع اختبارين ينجحان:

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

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

python
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())
text
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. ما الذي يجب أن يكون جاهزاً؟ بيئة افتراضية مثبت فيها الأدوات اللازمة، وشيفرة يمكن استيرادها من الاختبارات:

text
$ 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 (التحقق) — تحقق مما نتج

إليك العربة مختبرة من جديد — هذه المرة من خلال ما تعد به، لا من خلال طريقة تخزينها للأشياء:

python
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

على العربة المبنية على القائمة، ثم مرة أخرى على العربة المبنية على القاموس:

text
...                                                                      [100%]
3 passed in 0.01s
...                                                                      [100%]
3 passed in 0.01s

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

لماذا يهم أن يكون التنفيذ (Act) خطوة واحدة؟ لأنك عندما يفشل الاختبار، تريد أن تعرف أي شيء تعطل. إذا كان التنفيذ خمسة استدعاءات، فإن الفشل يشير إلى خمسة مشتبه بهم.

اختبر السلوك، لا التنفيذ الداخلي

القاعدة العملية: لا يجوز للاختبار أن يستخدم إلا ما يجوز للمستدعي استخدامه. بالنسبة للعربة، هذا يعني Cart() و .add() و .total(). وليس _items، ولا _lines. الشرطة السفلية في البداية هي طريقة بايثون في قول "هذا ملكي، وقد أغيره".

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

علامتان على أن الاختبار مرتبط بالتنفيذ الداخلي:

  • يقرأ خصائص تبدأ بـ _، أو يستخدم mock لدالة تستدعيها الشيفرة داخلياً
  • إعادة هيكلة لا يمكن لأي مستخدم ملاحظتها تجعله أحمر

تستحق الـ mocks ملاحظة هنا. فالـ mocks التي رأيتها في الفصل الحادي عشر هي الأداة الصحيحة عند الحدود — الشبكة، والساعة، وخدمة الدفع. أما إذا استُخدمت داخل شيفرتك أنت ("تحقق من أن _calculate استُدعيت بهذه الوسائط")، فإنها تصبح اختبارات للتنفيذ الداخلي تحت اسم آخر.

أسماء تُقرأ كجمل، وسبب واحد للفشل

شغّل اختبارات العربة الثلاثة مع -v:

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

python
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
text
$ 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، وعليك الآن أن تحل هذا اللغز. كما أن التحقق الثالث لم يُنفذ أبداً، فلا تعرف إن كان سينجح أم لا. والخطأ نفسه مع الاختبارات الثلاثة المركزة:

text
$ 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) في حلقة محكمة:

  1. أحمر — اكتب اختباراً صغيراً واحداً لسلوك غير موجود بعد، وشاهده يفشل
  2. أخضر — اكتب أقل قدر من الشيفرة يجعله ينجح
  3. إعادة هيكلة — رتّب الشيفرة، مع بقاء الاختبارات خضراء طوال الوقت

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

نأخذ الخطة من الجدول، صفاً تلو الآخر.

أحمر. الصف الأول، قبل أن يوجد slugs.py:

python
from slugs import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"
text
==================================== 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

هذا أحمر سليم تماماً. فالاختبار يطلب شيئاً غير موجود.

أخضر. أقل قدر من الشيفرة ينجح — وهو فعلاً الأقل:

python
def slugify(title):
    return title.lower().replace(" ", "-")
text
.                                                                        [100%]
1 passed in 0.01s

يبدو الأمر كأنه غش. لكنه مقصود: الشيفرة لا تفعل إلا ما طلبه اختبار حتى الآن. وأي شيء إضافي سيكون شيفرة لا يتحقق منها أي اختبار.

أحمر. الصف الثاني من الجدول:

python
def test_drops_punctuation():
    assert slugify("Hello, World!") == "hello-world"
text
.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

يحدد السطر ? بالضبط الحرفين اللذين لا ينبغي وجودهما.

أخضر. الآن لم تعد حيلة استبدال مسافة واحدة كافية. استبدل كل سلسلة من "ما ليس حرفاً أو رقماً" بشرطة واحدة، ثم احذف الشرطات من الطرفين:

python
import re


def slugify(title):
    return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
text
..                                                                       [100%]
2 passed in 0.01s

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

أحمر. الأحرف ذات العلامات:

python
def test_turns_accented_letters_into_plain_ones():
    assert slugify("Café au lait") == "cafe-au-lait"
text
..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:

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

إعادة هيكلة. الشيفرة تعمل، لكن جسم الدالة سطر واحد مكثف. أعطِ كل خطوة اسماً، وصرّف النمط مرة واحدة، وأضف تلميحات الأنواع (type hints) — دون تغيير أي سلوك:

python
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("-")
text
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) شكلها:

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

نقطة واحدة، لكن مئة قائمة من الأسعار ومئة خصم مرت عبره. ومن الاستراتيجيات الأخرى التي ستلجأ إليها: st.text()، و st.floats()، و st.booleans()، و st.sampled_from([...])، و st.dictionaries(...)، و st.builds(...).

ما الخصائص التي تملكها slugify؟ لا يمكنك أن تقول ما هو الـ slug لنص عشوائي، لكن يمكنك أن تقول كيف يجب أن يبدو، وأن تطبيق slugify على slug لا يغير شيئاً:

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

ينتج st.text() رموزاً تعبيرية (emoji)، ونصوصاً صينية، ومحارف تحكم، ونصوصاً فارغة — مدخلات ما كنت لتكتبها أبداً في جدول الخطة.

الذهاب والإياب يكشف خطأً حقيقياً

أكثر الخصائص إنتاجية هي الذهاب والإياب (round trip): إذا كان بإمكانك ترميز شيء ثم فك ترميزه مجدداً، فإن فك الترميز يجب أن يعيد الأصل. إليك مُرمّز طول التتابع (run-length encoder) — يصبح "aaab" هو "3a1b" — مع اختبارين بالأمثلة ينجحان:

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

python
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))
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، ليُشغّل في كل مرة، وعلى كل جهاز:

python
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")، وتكتسب الخاصية سطراً واحداً:

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

الخصائص لا تحل محل الأمثلة. فالاختباران بالأمثلة يوثقان الصيغة بطريقة يفهمها القارئ من نظرة واحدة؛ والخاصية تحرس الزوايا التي لم يفكر فيها أحد. استخدم الاثنين معاً.

الاختبارات المتقلبة (Flaky tests)

الاختبار المتقلب (flaky) ينجح ويفشل على الشيفرة نفسها. وهو أسوأ من عدم وجود اختبار: إذ يتعلم الناس الضغط على "إعادة التشغيل" ويكفّون عن تصديق اللون الأحمر. يأتي كل اختبار متقلب تقريباً من أحد أربعة أسباب، ولكل منها إصلاح يزيل السبب بدلاً من إخفائه.

الحالة المشتركة وترتيب الاختبارات

python
_users = set()


def register(name):
    _users.add(name)


def count():
    return len(_users)
python
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
text
$ 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:

python
class Registry:
    def __init__(self):
        self._users = set()

    def register(self, name):
        self._users.add(name)

    def count(self):
        return len(self._users)
python
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
text
..                                                                       [100%]
2 passed in 0.01s

عندما لا تستطيع تغيير الشيفرة، فإن البديل هو fixture من نوع autouse يعيد ضبط الحالة قبل كل اختبار وبعده. يجب أن ينجح كل اختبار وحده، وبأي ترتيب.

الوقت

python
from datetime import datetime


def greeting(now=None):
    now = now or datetime.now()
    return "Good morning" if now.hour < 12 else "Good afternoon"
python
from greeting import greeting


def test_greets_the_morning():
    assert greeting() == "Good morning"  # true only before noon

الشيفرة نفسها، مُشغّلة في اللحظة نفسها، على جهازين مضبوطين على منطقتين زمنيتين مختلفتين:

text
$ 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 إليها. الاختبار الذي يتحكم في الساعة يختبر جانبي الحد، ويعطي الإجابة نفسها في أي ساعة:

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

تمرير الوقت كوسيط أبسط من أي mock. وعندما لا تستطيع تغيير التوقيع، فإن monkeypatch من الفصل العاشر يستبدل الساعة بدلاً من ذلك.

العشوائية

python
import random


def pick_winner(names, rng=random):
    return rng.choice(names)
python
from raffle import pick_winner


def test_picks_a_winner():
    assert pick_winner(["rahim", "karim", "salma"]) == "rahim"

ست مرات تشغيل، دون تغيير أي شيء:

text
$ 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) ثابتة:

python
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
text
$ 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، اكتب اختباراً يعيد إنتاج البلاغ وشاهده يفشل. هذا يثبت أن الاختبار يكتشف هذا الخطأ تحديداً:

python
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("!!!")
text
$ 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 كما يلي:

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

ينجح اختبار الانحدار والاختبارات الثلاثة بالأمثلة. لكن شغّل المجموعة كاملة، وستعترض اختبارات الخصائص:

text
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("") يرفع الآن استثناءً. هذا ليس خطأً جديداً — بل هو العقد يتغير عن قصد، والخصائص تلاحظ ذلك. هذا جيد. حدّثها لتعبر عن العقد الجديد: العناوين التي تحتوي على حرف أو رقم واحد على الأقل.

python
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) بالطريقة نفسها.

text
......                                                                   [100%]
6 passed in 0.01s

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


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

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

| الحالة | المدخل | المتوقع | |---|---|---| | فارغة | "" | weak | | كل الأنواع، لكنها قصيرة جداً | "Ab1!xyz" (7) | weak | | طويلة بما يكفي، نوع واحد | "abcdefgh" | weak | | طويلة بما يكفي، نوعان | "abcdefg1" | medium | | بطول 12، ثلاثة أنواع | "abcdefghij1!" | strong | | بطول 12، نوعان | "abcdefghijk1" | medium |

passwords.py:

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

python
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"
text
$ 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 جديد ينجح من أول تشغيل إنه لا يثبت أي شيء عن الشيفرة التي توشك على كتابتها. فإما أن السلوك موجود بالفعل (لا بأس — احتفظ بالاختبار كتوثيق)، أو أن الاختبار لا يتحقق مما تظن. اكسر الشيفرة عمداً للحظة وتأكد من أن الاختبار يصبح أحمر.