التطوير القائم على الاختبار

نظرة عامة على التطوير القائم على الاختبار.


يشير التطوير المدفوع بالاختبارات (TDD) إلى أسلوب في البرمجة تُكتب فيه الاختبارات لتوجيه تنفيذ تصميم البرنامج في الكود.

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

إعادة الهيكلة

إعادة الهيكلة هي إعادة كتابة الكود لتحسين تصميمه. وهي ليست مجرد إعادة كتابة الكود لإصلاح الـbug. ويُسمّى أحيانًا «إعادة الهيكلة» تعديلَ الكود لجعله ينجح في الاختبارات. ومع أن تعديل الكود قد يشمل تحسين التصميم كوسيلة لاجتياز الاختبارات، فإن مجرد تصحيح الأخطاء لا يعني بالضرورة تحسين تصميم الكود، ومن ثم فهو ليس بالضرورة إعادة هيكلة.

المثال التالي يوضح تصحيح الأخطاء دون إعادة هيكلة:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

المثال التالي يوضح إعادة الهيكلة، ثم تصحيح الأخطاء:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

TDD وPython على Exercism

يتبع مسار Python في Exercism منهجية TDD في تمارينه. فاختبارات الوحدة مكتوبة بالفعل. ويمكن للطالب الاطلاع على الاختبارات لفهم ما يتطلبه اجتياز الحل بمزيد من التفصيل. وقد يُزوَّد الطالب بهيكل أولي للحل.

تشخيص اختبار فاشل على Exercism في محرّر الويب

عندما يفشل اختبار واحد أو أكثر في حل بلغة Python، فلن تكون للمهمة (أو المهام) المقابلة خلفية خضراء. وستكون منطقة أول مهمة فاشلة موسّعة، وستبدو ترويستها على نحو يشبه

Task 1 Extract coordinates -

النقر على علامة الطرح سيطوي المهمة، حتى نتمكن من النظر في مهام أخرى، لكننا سنبقى مع هذه المهمة في الوقت الحالي.

وستكون أسفلها منطقة اختبار موسّعة تبدو على نحو يشبه

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

حيث يشير Tisbury Treasure إلى التمرين، ويشير get_coordinate إلى الدالة أو الطريقة الفاشلة.

عادة ما يكون Test 1 نوعًا من قوالب يضم قسمًا برمجيًا لإعداد الاختبارات. ولا يحتوي على معلومات عن الاختبار أو الاختبارات المحددة التي فشلت. وسيذكر في جزئه السفلي أن

One or more variations of this test failed. Details can be found under each [variant#].

النقر على ⌄ سيطوي الاختبار.

وستكون أسفله اختبار مطويّ يبدو على نحو يشبه:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

ويختلف شكل ذلك تبعًا للعرض المضبوط للوحة الجانبية اليمنى. النقر على > سيوسّع الاختبار. وستُعرض على الأرجح بيانات الإدخال وبيانات النتيجة المتوقعة في قسم برمجي. وقد تكون البيانات لجميع اختبارات هذه المهمة. وفي الأسفل، في قسم Test Failure، يوجد السبب المحدد لفشل هذا الاختبار. وقد يبدو على نحو يشبه هذا:

AssertionError: ['2A'] != '2A'

في هذه الحالة تحديدًا، يشير ذلك إلى أن القيمة المُرجَعة ['2A'] لم تساوِ القيمة المتوقعة '2A'.

وإذا نظرنا إلى الكود الخاص بـ get_coordinate نراه منفَّذًا على النحو التالي

def get_coordinate(record):
    return [record[1]]

إذا أزلنا قوسي القائمة (مثل return record[1]) وأعدنا تشغيل الاختبارات، فستنجح اختبارات المهمة 1.

وإذا ظلت مهمة واحدة أو أكثر فاشلة، تُكرَّر العملية السابقة مع كل منها حتى تنجح جميع الاختبارات.

أحيانًا تكون البيانات المتوقعة والبيانات المُرجَعة أكبر من أن تُدرج كاملة في قسم Test Failure. وقد يبدو على نحو يشبه هذا:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

وقد تظل هناك بيانات كافية لمعرفة موضع المشكلة. في الحالة أعلاه، هناك محرفا سطر جديد مُرجَعان (مثل \n\n(\'Brass Spyglass) بينما المتوقع واحد فقط (مثل \n(\'Brass Spyglass).

بعد نجاح جميع الاختبارات

تهانينا! لقد نجحت جميع الاختبارات. ما التالي؟ يمكنك نشر الحل على الفور. أو، بما أن الكود يعمل الآن، إذا أردت إعادة هيكلته لأي سبب، يمكنك تعديل الكود وإرسال نسخة أخرى. وإذا كنت ترى أن الكود يمكن أن يكون أفضل لكنك لا تعرف كيف، يمكنك طلب الإرشاد بشأن الحل. وإذا توفّر مُرشد، فقد يتواصل معك بأفكار لمقاربات أخرى للحل. وعند نشر حلّك، يمكنك السماح بالتعليقات، وقد ينتهز الطلاب الآخرون الفرصة لكتابة تعليقات أو طرح أسئلة.

التحسين من أجل الأداء

رغم أن «التحسين المبكر أصل كل الشرور» (مقولة تُنسب إلى كل من Tony Hoare وDonald Knuth)، فإن هناك وقتًا يأتي، حتى مع عمل الحل، ترغب فيه في تحسين أداء الحل. وقد تكون إحدى هذه الأوقات عندما ينجح الحل في بعض الاختبارات لكنه ينتهي وقته في اختبارات أخرى. وقد يكون من المفيد أن تعرف بدقة مقدار الوقت الذي يستغرقه جزء من الكود. ويمكن استخدام وحدة timeit لقياس زمن تنفيذ الكود حتى لفترات صغيرة جدًا. ويمكن لدالة timeit أن تأخذ حتى خمسة وسائط: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None). يحدد المعامل stmt الكود الفعلي الذي سيُشغَّل ويُقاس زمنه. ويحدد المعامل number عدد المرات التي سيُشغَّل فيها كود stmt. ويحدد المعامل setup الكود الذي يُشغَّل مرة واحدة فقط استعدادًا لتشغيل كود stmt. ويُحتسب زمن تشغيل كود setup ضمن الزمن الكلي. وكلما زاد عدد المرات التي يُشغَّل فيها كود stmt، قلّ نصيب زمن setup من كل مرة. ويتيح المعامل timer تمرير Timer مختلف عن الافتراضي. والوسيط الافتراضي للمعامل timer هو perf_counter، وهو كافٍ في معظم الحالات. والوسيط الافتراضي للمعامل number هو 1_000_000. ويحدد المعامل globals فضاء أسماء يُنفَّذ فيه الكود. والوسيط الافتراضي للمعامل globals هو None.

المثال التالي يوضح استخدام timeit لمعرفة المدة التي يستغرقها تحديد ما إذا كانت جملة تحتوي على كل حروف العلة الإنجليزية:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

استغرق تشغيل الكود مليون مرة في المتوسط 4.965089999896008e-07 ثانية لكل استدعاء (نحو 497 نانوثانية لكل استدعاء).

المثال التالي لمعرفة ما إذا كان إخراج استدعاء casefold خارج استيعاب القائمة يوفر أي وقت:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

استغرق تشغيل الكود مليون مرة في المتوسط 4.923898000270128e-07 ثانية لكل استدعاء (نحو 492 نانوثانية لكل استدعاء.) وهكذا، وفّر إخراج casefold خارج استيعاب القائمة نحو 5 نانوثانية لكل استدعاء، أي نحو 5 مللي ثانية إجمالًا لمليون استدعاء.

يمكن أيضًا استخدام cProfile لتحليل أداء الكود، لكنه ليس بنفس دقة التفصيل، لأنه لا ينزل إلى ما دون مدد المللي ثانية.