الاختبار (Testing)

لا يقتصر البرمجة (programming) على كتابة التعليمات البرمجية فقط. من المهم التحقق من أن التعليمات البرمجية تفعل ما ينبغي عليها فعله. تسمى عملية التحقق من أن البرنامج يعمل كما هو متوقع الاختبار (testing).

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

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

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

تثبيت مكتبة pytest (Installing the pytest library)

حتى الآن، استخدمنا فقط الوحدات (modules) التي تأتي مثبتة مع بايثون، على سبيل المثال، وحدات مثل math أو turtle. هناك العديد من المكتبات (libraries) الأخرى التي لم يتم تضمينها في بايثون ولكن يمكنك تثبيتها في بيئة بايثون الخاصة بك واستخدامها.

تسمى مكتبة الاختبار في بايثون unittest. من الصعب جدًا استخدام هذه المكتبة لذا سنستخدم مكتبة أفضل. سنقوم بتثبيت مكتبة pytest التي هي أسرع وأسهل في الاستخدام وشائعة جدًا.

أرسل الأمر التالي. (إنه أمر سطر أوامر - command-line command، تمامًا مثل cd أو mkdir؛ لا تدخله في وحدة تحكم بايثون - Python console.)

$ python -m pip install pytest

ما هو pip ولماذا نستخدمه؟

pip هي أداة سطر أوامر بايثون (Python command-line tool) لتثبيت مكتبات الطرف الثالث (3rd-party) مكتبات بايثون من فهرس حزم بايثون (PyPI) ومصادر أخرى (مثل مستودعات Git).

python -m pip install pytest يجعل بايثون يقوم بتثبيت مكتبة pytest من PyPI. للحصول على مساعدة حول كيفية استخدام pip، قم بتشغيل python -m pip --help.

` `

<python -m <command يخبر بايثون بتنفيذ نص (script) من وحدة بايثون (Python module) المسماة <command> (على سبيل المثال، python -m pip ...). في بيئة بايثون مهيأة بشكل صحيح، يجب أن يكون من الممكن استدعاء <command> مباشرة، بدون مساعدة أمر python (على سبيل المثال، pip ...)

لتوفير عناء التعقيدات غير الضرورية مع بيئة بايثون قد تكون مهيأة بشكل خاطئ، نوصي باستخدام النسخة الأطول <python -m <command.

كتابة الاختبارات (Writing tests)

سنعرض الاختبار من خلال مثال بسيط للغاية. هناك دالة (function) add يمكنها جمع رقمين. هناك دالة أخرى تختبر ما إذا كانت ترجع الدالة add نتائج صحيحة لأرقام محددة.

انسخ الكود إلى ملف باسم test_addition.py في مجلد فارغ جديد.

يعد تسمية الملفات ودوال الاختبار مهمًا بالنسبة لـ pytest (بالإعدادات الافتراضية (default settings)). من المهم أن تبدأ أسماء الملفات التي تحتوي على الاختبارات ودوال الاختبار بـ test_.

def add(a, b):
    return a + b

def test_add():
    assert add(1, 2) == 3

تسمية الملفات ودوال الاختبار مهمة

يقوم pytest بفحص التعليمات البرمجية الخاصة بك و يبحث عن الاختبارات المضمنة. عند العثور عليها، يتم تنفيذ هذه الاختبارات.

بشكل افتراضي، يجب أن تبدأ أسماء ملفات الاختبار ودوال الاختبار بـ البادئة test_ ليتم التعرف عليها كاختبارات.

ماذا تفعل دالة الاختبار؟

يقوم بيان assert بتقييم التعبير الذي يليه. إذا كانت النتيجة غير صحيحة، فإنه يثير استثناء AssertionError الذي يفسره pytest على أنه اختبار فاشل. يمكنك أن تتخيل أن assert a == b يفعل ما يلي:

if not (a == b):
    raise AssertionError

لا تستخدم assert خارج دوال الاختبار في الوقت الحالي. بالنسبة للتعليمات البرمجية "العادية"، فإن assert لديه وظائف لن نشرحها الآن.

تشغيل الاختبارات (Running tests)

تقوم بتنفيذ الاختبارات باستخدام الأمر <python -m pytest -v <path متبوعًا بمسار الملف الذي يحتوي على الاختبارات.

يمكنك حذف <filename> و بذلك فان الامرpython -m pytest -v يفحص المجلد الحالي ويقوم بتشغيل الاختبارات في جميع الملفات التي تبدأ أسماؤها بـ البادئة test_.

يمكنك أيضًا استخدام مسار إلى مجلد حيث يجب أن يبحث pytest عن الاختبارات.

يفحص هذا الأمر الملف المحدد ويستدعي جميع الدوال التي تبدأ بالبادئة test_. يقوم بتنفيذها ويتحقق مما إذا كانت تثير أي استثناء، على سبيل المثال، تم إثارته بواسطة بيان assert.

$ python3 -m pytest -v test_addition.py
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-7.1.2, pluggy-1.0.0
rootdir: /tmp/test_example
collected 1 item

test_addition.py .                                                       [100%]

============================== ␐[1m1 passed␐[0m in 0.00s␐[0m ===============================

في حالة حدوث استثناء، يعرض pytest رسالة حمراء مع تفاصيل إضافية يمكن أن تساعدك في العثور على الخطأ وإصلاحه:

============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-7.1.2, pluggy-1.0.0
rootdir: /tmp/test_example
collected 1 item

test_addition.py F                                                       [100%]

=================================== FAILURES ===================================
___________________________________ test_add ___________________________________

    def test_add():
>       assert add(1, 2) == 3
E       assert 4 == 3
E        +  where 4 = add(1, 2)

test_addition.py␐[0m:5: AssertionError
=========================== short test summary info ============================
FAILED test_addition.py::test_add - assert 4 == 3
============================== ␐[1m1 failed␐[0m in 0.01s␐[0m ===============================

حاول تشغيل الاختبار بنفسك. قم بتعديل الدالة add أو (اختبارها) بحيث يفشل الاختبار.

وحدات الاختبار (Test modules)

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

قسّم ملف test_addition.py: انقل الدالة add إلى وحدة جديدة addition.py. في ملف test_addition.py، احتفظ بالاختبار فقط. إلى ملف test_addition.py، أضف from addition import add في الأعلى حتى يتمكن الاختبار من استدعاء الدالة المختبرة.

يجب أن ينجح الاختبار مرة أخرى.

هيا نحاول الآن إضافة اختبارين مختلفين لدالة لحساب محيط مستطيل من دوال مخصصة

def find_perimeter(width, height):
    "Returns the rectangle's perimeter of the given sides"
    return  2 * (width  +  height)
print(find_perimeter(2, 4)) # this is how you'd normally check result without "testing"

الحل

وحدات قابلة للتنفيذ (Executable modules)

الاختبارات الآلية (Automated tests) هي دوال تتحقق، بدون تدخل يدوي، من أن جميع ميزات البرنامج المختبر تعمل بشكل صحيح. لا يمنحنا الاختبار دليلًا بنسبة 100٪ على أن الكود خالٍ من الأخطاء ولكنه لا يزال أفضل من عدم الاختبار على الإطلاق.

تجعل الاختبارات الآلية تعديل الكود أسهل حيث يمكنك العثور على الأخطاء المحتملة في الوظائف الموجودة بشكل أسرع (المعروفة باسم التراجعات - regressions).

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

مثال ل(repository) بايثون مع pytest.

من الناحية العملية، هذا يعني أن الاختبارات يجب ألا تعتمد على تفاعل مباشر مع المستخدم، على سبيل المثال، دالة input لن تعمل في الاختبارات.

هل يمكننا اختبار تفاعل المستخدم في الاختبارات الآلية؟

هناك تقنيات اختبار تسمح لنا بمحاكاة تفاعل المستخدم في واجهات المستخدم. لكن هذا خارج نطاق هذه الدورة.

قد يجعل هذا عملك أصعب في بعض الأحيان. لنلقِ نظرة على مشروع أكثر تعقيدًا، لعبة XO أحادية البعد (1D tic-tac-toe).

إذا لم يكن لديك برنامج XO أحادي البعد، فإن الأقسام التالية هي نظرية فقط.

إذا كنت تدرس في المنزل، فأكمل درس XO أحادي البعد قبل المتابعة. وصف المهمة موجود في XO أحادي البعد

يبدو هيكل كود XO أحادي البعد تقريبًا كما يلي:

import random  # (and possibly other import statements that are needed)
# (وربما عبارات استيراد أخرى ضرورية)

def move(board, space_number, mark):
    """Returns the board with the specified mark placed in the specified position"""
    # تُرجع اللوحة مع العلامة المحددة الموضوعة في الموضع المحدد
    ...

def player_move(board):
    """Asks the player what move should be done and returns the board
    with the move played.
    """
    # تسأل اللاعب عن الحركة التي يجب القيام بها وتُرجع اللوحة
    # مع الحركة التي تم لعبها.
    ...
    input('What is your move? ')
    ...

def computer_move(board):
    """Places computer mark on random empty position and returns the board
    with the move played.
    """
    # تضع علامة الكمبيوتر في موضع فارغ عشوائي وتُرجع اللوحة
    # مع الحركة التي تم لعبها.
    ...

def tic_tac_toe_1d():
    """Starts the game

    It creates an empty board and runs player_move and computer_move alternately
    until the game is finished.
    """
    # تبدأ اللعبة
    # تقوم بإنشاء لوحة فارغة وتشغل player_move و computer_move بالتناوب
    # حتى تنتهي اللعبة.
    while ...:
        ...
        player_move(...)
        computer_move(...)
        ...

# Start the game:
# ابدأ اللعبة:
tic_tac_toe_1d()

كما وصفنا في درس الوحدات (modules)، إذا قمت باستيراد هذه الوحدة (module)، فإن بايثون تنفذ جميع الأوامر الموجودة فيها، من الأعلى إلى الأسفل:

  • يقوم الأمر الأول، import، بتهيئة المتغيرات والدوال الخاصة بـ وحدة random. إنها وحدة من مكتبة بايثون القياسية ومن غير المحتمل أن يكون لها أي تأثير جانبي يدعو للقلق.

  • تعريفات الدوال (بيانات def وكل ما بداخلها) تقوم فقط بتعريف الدوال ولكنها لا تنفذها.

  • يبدأ استدعاء الدالة tic_tac_toe_1d اللعبة. تستدعي tic_tac_toe_1d الدالة ()player_move التي تستدعي ()input. هذه مشكلة.

إذا قمت باستيراد هذه الوحدة (module) إلى الاختبارات، فإن input يفشل ولا يتم استيراد الوحدة.

إذا كنت ترغب في استيراد مثل هذه الوحدة من مكان آخر، على سبيل المثال، كنت ترغب في استخدام ()move في لعبة مختلفة، فإن استيراد الوحدة نفسها سيبدأ لعبة XO أحادية البعد!

يعد استدعاء tic_tac_toe_1d تأثيرًا جانبيًا ونحتاج إلى إزالته. حسنًا، لكن لا يمكنك بدء اللعبة بدونها! هناك طريقتان لإصلاح ذلك.

تقسيم وحدة (Splitting a module)

يمكننا إنشاء ملف بايثون جديد فقط لتشغيل اللعبة، بينما ستبقى الدوال في الملف القديم. على سبيل المثال، قم بإنشاء ملف جديد باسم game.py وننقل استدعاء ()tic_tac_toe_1d إليه:

import tic_tac_toe

tic_tac_toe.tic_tac_toe_1d()

لا يمكنك اختبار هذه الوحدة لأنها تستدعي input بشكل غير مباشر. ولكن يمكنك تنفيذه إذا كنت ترغب في اللعب كـ python game.py

يمكنك استيراد الوحدة الأصلية في ملفات الاختبار أو الوحدات الأخرى بدون آثار جانبية.

يمكن أن يبدو اختبار الوحدة الأصلية كما يلي:

import tic_tac_toe

def test_move_to_empty_space():
    board = tic_tac_toe.computer_move('--------------------')
    assert len(board) == 20
    assert board.count('x') == 1
    assert board.count('-') == 19

تشغيل جزء من الكود فقط إذا تم تنفيذ الوحدة مباشرةً (Run part of code only if module is executed directly)

هناك طريقة خاصة للتحقق مما إذا كانت بايثون تستورد فقط الدوال (functions) من ملف أو إذا كانت تنفذه مباشرةً. من الممكن ذلك عن طريق مقارنة قيمة متغير (variable) "سحري" __name__.

يتوفر المتغير (variable) __name__ في أي وقت تقوم فيه بتشغيل برنامج بايثون (Python program)، وإذا كانت قيمته __main__، فقد تم تشغيله من النص الرئيسي (main script). وإذا لم يكن كذلك، فقد تم استيراده فقط.

if __name__ == "__main__":
    tic_tac_toe_1d()

الآن يمكنك استيراد الوحدة (module) الأصلية في ملفات الاختبار (test files) أو وحدات أخرى (other modules) بدون آثار جانبية (side effects) وتشغيلها للعب اللعبة.

أفضل ممارسات الاختبار (Best Practices for Testing)

  • اكتب حالات اختبار واضحة وموجزة (Write Clear, Concise Test Cases): يجب أن يركز كل اختبار على جانب محدد من التعليمات البرمجية الخاصة بك.
  • استخدم أسماء اختبار وصفية (Use Descriptive Test Names): يجب أن تكون أسماء الاختبارات وصفية لما تختبره.
  • حافظ على استقلالية الاختبارات (Keep Tests Independent): يجب ألا تعتمد الاختبارات على بعضها البعض.
  • قم بتشغيل الاختبارات بانتظام (Run Tests Regularly): قم بدمج الاختبار في عملية التكامل المستمر (continuous integration process) الخاصة بك (في كل مرة يقوم شخص ما بالدفع إلى مستودع - repository).