توسعه‌ی آزمون‌محور

مروری بر توسعه‌ی آزمون‌محور.


توسعهی تست-محور (TDD) به سبکی از برنامه‌نویسی اشاره دارد که در آن تست‌ها برای هدایت پیاده‌سازی طراحی برنامه در کد نوشته می‌شوند.

یک یا چند تست (به‌ویژه تست‌های واحد) پیش از کدنویسی نوشته می‌شوند. تست‌ها برای پوشش یک جنبه از رفتار برنامه در نظر گرفته شده‌اند، که ممکن است بر یک تابع یا متد واحد متمرکز باشد. نوشتن تست‌ها راهی برای تبدیل نیازمندی‌های برنامه و معماری کلی به طراحی مخصوص پیاده‌سازی است. تست‌ها اجرا می‌شوند و باید شکست بخورند، چون کد هنوز پیاده‌سازی نشده است. کد پیاده‌سازی می‌شود و تست‌ها دوباره اجرا می‌شوند. اگر تست‌ها قبول شوند، آنگاه یا پیاده‌سازی آن رفتار تمام شده است یا شاید تست‌های لازم دیگری باقی مانده باشند. اگر تست‌ها قبول نشوند، آنگاه کد دیباگ می‌شود و تست‌ها دوباره اجرا می‌شوند. چرخه‌ی تست و کدنویسی تکرار می‌شود تا همه‌ی تست‌های لازم قبول شوند، و در آن نقطه پیاده‌سازی آن جنبه از رفتار برنامه تمام می‌شود... فعلاً.

بازآرایی

بازآرایی بازنویسی کد برای بهبود طراحی آن است. این صرفاً بازنویسی کد برای رفع باگ نیست. گاهی به تغییر دادن کد برای قبولی تست‌ها «بازآرایی» گفته می‌شود. اگرچه تغییر کد ممکن است شامل بهبود طراحی به‌عنوان راهی برای قبولی تست‌ها باشد، اما صرفاً دیباگ کردن لزوماً به بهبود طراحی کد منجر نمی‌شود، و بنابراین لزوماً بازآرایی نیست.

مثال زیر نمونه‌ای از دیباگ کردن بدون بازآرایی است:

# 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 باز شده قرار دارد که چیزی شبیه این است

       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]) و تست‌ها را دوباره اجرا کنیم، تست‌های Task 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).

پس از قبولی همه‌ی تست‌ها

تبریک! همه‌ی تست‌ها قبول شدند. قدم بعدی چیست؟ راه‌حل را می‌توان بلافاصله منتشر کرد. یا حالا که کد کار می‌کند، اگر به هر دلیلی می‌خواهید آن را بازآرایی کنید، می‌توانید کد را تغییر دهید و یک تکرار دیگر ارسال کنید. اگر فکر می‌کنید کد می‌تواند بهتر باشد اما نمی‌دانید چگونه، می‌توانید برای راه‌حل درخواست منتورینگ کنید. اگر منتوری در دسترس باشد، ممکن است برای ایده‌های رویکردهای دیگر به راه‌حل با شما تماس بگیرد. هنگام انتشار راه‌حل خود، می‌توانید اجازه‌ی دیدگاه دادن را فعال کنید و دیگر دانشجویان ممکن است فرصت را غنیمت شمرده و دیدگاه بگذارند یا سؤال بپرسند.

بهینه‌سازی برای کارایی

اگرچه «بهینه‌سازی زودهنگام ریشه‌ی همه‌ی بدی‌هاست» (گفته‌ای که به تونی هور و دانلد کنوث نسبت داده شده است)، زمانی فرا می‌رسد که با وجود کارکردن راه‌حل، می‌خواهیم کارایی آن را بهبود دهیم. یکی از آن زمان‌ها ممکن است وقتی باشد که راه‌حل برخی تست‌ها را قبول می‌شود اما برای بعضی دیگر به پایان زمان می‌خورد. می‌تواند مفید باشد که بدانیم یک تکه کد دقیقاً چقدر زمان می‌برد. می‌توان از ماژول 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 هم می‌توان برای پروفایل کردن کد استفاده کرد؛ با این حال، به اندازه‌ی timeit ریزدانه نیست، چون فقط تا بازه‌های میلی‌ثانیه‌ای پایین می‌رود.