آموزش توسعه تست محور یا TDD (Test-Driven Development)

منبع عکس unsplash
منبع عکس unsplash

به یاد دارم وقتی که TDD به گوشم خورد اصلا چیزی ازش نفهمیدم. یکم بیشتر مطالعه کردم، گیج‌تر شدم و به نظرم کاری عبث میومد! توی فروم‌ها و محفل‌های برنامه‌نویسی معمولا برنامه‌نویس‌هایی که از TDD استفاده می‌کردن و با تجربه‌تر بودن، برنامه‌نویس هایی که از TDD استفاده نمی‌کردن رو برنامه‌نویس حساب نمی‌کردن! این کار در خیلی از موارد باعث می‌شه که افراد کمتری بیان سمت موضوع و دلزده و دلسرد بشن. اینا به علاوه تنبلی ? دلایلی بودن که مدت زیادی سمت یادگیری و استفاده از توسعه تست محور (TDD) نرفتم.

دلیلی که این مطلب رو می‌نویسم این هست که چیزایی که در مورد توسعه تست محور متوجه شدم رو بگم که راهنمایی باشه برای افرادی که تازه می‌خوان به این حوزه وارد بشن تا سردرگمی که من داشتم رو تجربه نکنن و شروع کنن به توسعه تست محور.

ممکنه شما قبلا در مورد TDD خونده باشید ولی تا حالا هیچ تستی ننوشته باشید یا اینکه اگر نوشتید به صورت توسعه تست محور نبوده. امید هست که برای این دسته از برنامه نویس ها هم این مطالب مفید باشن و کمکی باشه برای شروع توسعه تست محور.

مطالبی که قراره بگم:

  • TDD چی هست؟ (تعریف توسعه تست محور)
  • چرا باید به صورت TDD برنامه نویسی کنیم؟
  • چطوری TDD رو شروع کنیم و توش پیش بریم؟

پیش‌نیازها:

توی این پست قراره روی اصول کلی تمرکز کنیم، ولی خب فرض بر این هست که تا حدودی با مفاهیم برنامه‌نویسی و Object Oriented آشنا هستید. همچنین زبان برنامه‌نویسی که برای نوشتن مثال‌ها به کار برده شده Java هست، پس قاعدتا انتظار می‌ره که این زبان رو تا حدودی بلد باشید.

TDD چی هست؟ (تعریف توسعه تست محور)

توسعه تست محور یا TDD (Test-Driven Development) به عنوان بخشی از متدولوژی Extreme Programming (XP) در اواخر دهه نود میلادی توسط Kent Beck توسعه پیدا کرد (درواقع درست‌تر اینه که بگیم کشف دوباره شد (rediscovered)).

در واقع توسعه تست محور مجموعه‌ای از تکنیک‌ها و اصول برنامه‌نویسی هستش که تا زمانی که به نتیجه مورد نظر برسید (کدی که مد نظر دارید) سه مرحله زیر در یک چرخه تکرار می‌شن:

  • نوشتن یک تست: در مرحله اول، قبل از نوشتن کد، یک تست ناموفق خودکار (Failing automated test) باید بنویسید.
  • پاس کردن تست: باید با حداقل تغییر ممکن در کد اصلی، تستی که در مرحله قبل نوشتید رو پاس کنید.
  • Refactor یا حذف Duplication
چرخه توسعه تست محور - منبع عکس ویکی پدیا
چرخه توسعه تست محور - منبع عکس ویکی پدیا


یادتون باشه که هدف، نوشتن کد تمیز (Clean code) ی است که به درستی کار میکنه. دقت کنید که اول، بخش دوم جمله قبل رو باید حل کنید. به این معنی که کدتون اول باید به درستی کار کنه. بعد از اون، کد رو اصطلاحا Refactor می‌کنیم تا به یک Clean code برسیم.

برای اینکه موضوع روشن‌تر بشه با یک مثال پیش می‌ریم. خب فرض کنید قصد نوشتن یک برنامه به صورت زیر رو داریم:

یک میوه فروشی رو در نظر بگیرید که کلا دو تا میوه داره. هندونه و انگور. قراره برای این میوه فروشی یک برنامه بنویسیم که اول قیمت این میوه‌ها رو به کاربر نشون می‌ده و بعد از کاربر میخواد که به عنوان ورودی، مقدار میوه‌هایی که میخواد بخره رو وارد کنه. در آخر قیمت کل رو به کاربر نشون بده.

برای شروع، اولین کاری که به ذهنتون می‌رسه چی هست؟

خب اگر جوابتون نوشتن کلاس مدل برای هر کدوم از میوه‌ها (و نوشتن یک Interface برای میوه) هست، باید بگم که اگر بخوایم به صورت TDD بریم جلو، اشتباه حدس زدید! قانون اول TDD میگه که قبل از هر کاری باید اول تست بنویسیم (در واقع یک failing test). خب، چطوری باید قبل از نوشتن کد، براش تست بنویسیم؟ اصلا چرا باید این کار رو انجام بدیم؟

چرایی این ماجرا رو پایین‌تر توضیح می‌دم. چون‌که اگر اول به این روش پیش بریم و مثال رو ببینید احتمالا چراییش رو بهتر درک می‌کنید. خب، برای نوشتن تست قبل از اینکه کدی بنویسیم فرض می‌کنیم کد وجود داره. با این فرض یه تست می‌نویسیم که درستی کد رو بسنجیم. خب به مثالمون برگردیم. فرض کنید قیمت هر کیلو هندوانه ۱۰۰۰ تومن و قیمت هر کیلو انگور ۱۵۰۰۰ تومن باشه. خب حالا می‌خوایم تستی بنویسیم که نشون بده قیمت ۴ کیلو هندوانه ۴۰۰۰ تومن هست.

یادتون باشه زمانی که دارید تست می‌نویسید،‌ تصور کنید که بهترین کد رو در اختیار دارید. در واقع وقتی کدی وجود نداره بهتره که تصور کنیم بهترین کد ممکن چطور رابط (Interface) ی می‌تونه داشته باشه. ما داریم قصه‌ای برای خودمون تعریف می‌کنیم که method ها از بیرون چطور در دسترس قرار می‌گیرن. قرار نیست همیشه این قصه به حقیقت بپیونده، اما بهتره که همیشه با بهترین API ممکن شروع کنیم.

خب با این تفاسیر تستی که می‌نویسیم به صورت زیر خواهد بود:

import org.junit.jupiter.api.Test;

public class TestFruitShop {
  @Test
  public void testMelonPrice() {
    Fruit melon = new Melon();
    assert(melon.getPrice(4) == 4000);
  }
}

قبل از اجرای تست، کدی که نوشتیم رو توضیح بدم. اینجا کاری که میخوایم انجام بدیم اینه که تست کنیم آیا قیمت ۴ کیلو هندوانه درست هست یا نه (یعنی ۴۰۰۰ تومن).

خب برای این کار اول یه متد می‌سازیم و با annotation Test اون رو مشخص می‌کنیم. اسم متد رو چیزی می‌زاریم که نشون دهنده تستی باشه که می‌خوایم بنویسیم.

بعد فرض می‌کنیم که یک Interface میوه (Fruit) داریم و یک کلاس Melon که Fruit interface رو پیاده سازی کرده (Implement). همچنین فرض می‌کنیم که Fruit Interface دارای یک متد هست به اسم getPrice(double weight) که کلاس هایی که این Interface رو پیاده‌سازی می‌کنند باید این متد رو هم Override کنند.

دقت کنید که این کدی هست که من توی ذهنم تصور کردم و لزوما درست نیست. درضمن ممکنه توی آینده تغییر کنه. مثلا متوجه بشم که ساخت یک Interface کار درستی نبوده و یا اینکه متد getPrice باید پارامترهای دیگه‌ای هم داشته باشه و ... .

بعد از اینکه یک Object از کلاس Melon ساختیم، با استفاده از متد assert چک می‌کنیم که آیا قیمت ۴ کیلو هندوانه برابر ۴۰۰۰ تومن هست یا نه. متد assert از کتابخانه Junit هستش. Junit یک Framework هستش که ازش برای نوشتن تست استفاده می‌کنیم. البته Junit متدهای دیگری هم داره که می‌تونیم ازشون استفاده کنیم، مثل assertTrue و امثالهم، ولی فعلا با همون assert پیش می‌ریم.

دیدید که تست نوشتن ترس نداره و چیز پیچیده‌ای نیست ?

حالا تست رو اجرا کنیم و نتیجه رو ببینیم:


همونطور که در تصویر می‌بینید، تست Failed شده و سه تا خطا داریم. اول اینکه Fruit interface وجود نداره، و دوم اینکه کلاس Melon وجود نداره، و آخر اینکه متد getPrice تعریف نشده هست. خب برای پاس کردن تست، باید اول Fruit interface رو بسازیم و بعد کلاس Melon رو.

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


public interface Fruit {
  double getPrice(double weight);
}


public class Melon implements Fruit {
  @override
  public double getPrice(double weight) {
    return 0;
  }
}

خب حالا اگر تست رو دوباره اجرا کنیم، خطایی که می‌گیریم AssertionError هست. چون‌که همونطور که توی قطعه کد بالا می‌بینید، متد getPrice همواره عدد صفر رو بازگشت می‌ده.

خب بنظرتون حالا باید چیکار کنیم؟

طبق نکته‌ای که قبل‌تر گفته شد، هدف توی این مرحله پاس کردن تست هست، نه چیز دیگه. در نتیجه با حداقل تغییر باید تست رو پاس کنیم. خب حداقل تغییر برای پاس کردن کد توی اینجا اینه که عدد ثابت ۴۰۰۰ رو بازگشت بدیم.

بازگشت دادن یک مقدار ثابت از یک متد و مرحله به مرحله، جاگزین کردنش با متغیرها تا زمانیکه به کد واقعی خود برسیم یک تکنیک ساده و کاربردیه که توی نوشتن تست خیلی ازش استفاده می‌شه.

درسته، خیلی مشخصه که باید مقدار weight ضربدر ۱۰۰۰ رو از متد getPrice بازگشت بدیم، ولی اولا اینجا بحث آموزشی هستش و من می‌خوام به شما نشون بدم که منظور از «قدم کوچیک برداشتن» چی هست، ثانیا فقط در مواقعی باید یک ضرب پیاده سازی رو انجام بدیم که اون راه‌حل رو بلد باشیم و نیازی به فکر کردن زیاد برای پیدا کردنش نداشته باشیم و بتونیم در عرض چند ثانیه اون رو بنویسیم.

در نتیجه کلاس Melon به صورت زیر تغییر خواهد کرد:

public class Melon implements Fruit {
  @override
  public double getPrice(double weight) {
    return 4000;
  }
}

حالا اگر تست رو دوباره اجرا کنیم، بالاخره تیک سبز رنگ رو که نشون می‌ده تست پاس شده، خواهیم دید ?

حالا وقتشه که کد رو Refactor کنیم و Duplication ها رو حذف کنیم. ولی Duplication یعنی چی؟

در اکثر موارد Duplication به شکل تکرار یک قطعه کد خودش رو نشون می‌ده و نشانه (Symptom) وجود مشکل در کد هست. اما کد ما Duplication نداره. مشکل کد ما وابستگی (Dependency) بین کد و تست هست. به این معنی که الان ما نمی‌تونیم یک تست دیگه که منطقی به نظر میاد بنویسیم بدون اینکه کدمون رو تغییر بدیم.

مثلا فرض کنید میخوایم تستی بنویسیم که قیمت ۲ کیلو هندوانه رو تست می‌کنه. خب وقتی همچین تستی بنویسیم، از اونجا که متد getPrice همواره مقدار ثابت ۴۰۰۰ رو بازگشت می‌ده، تستمون پاس نمی‌شه و برای پاس کردنش مجبوریم که کد رو تغییر بدیم.

Dependency یکی از مشکلات اصلی در توسعه نرم‌افزار در هر مقیاسی است.

خب، پس الان یه تست دیگه می‌نویسیم که قیمت دو کیلو هندوانه رو چک کنه:

import org.junit.jupiter.api.Test;

public class TestFruitShop {
  @Test
  public void testMelonPrice() {
    Fruit melon = new Melon();
    assert(melon.getPrice(4) == 4000);
    assert(melon.getPrice(2) == 2000);
  }
}

اما همونطور که قبلا گفتیم، این تست پاس نمی‌شه چونکه assert دوم قیمت دو کیلو هندوانه رو تست می‌کنه در صورتیکه متد getPrice در کلاس Melon همواره عدد ثابت ۴۰۰۰ رو بازگشت می‌ده.

حالا باید با تغییراتی در کد، این تست رو پاس کنیم. الان وقتشه که پیاده‌سازی متد getPrice رو تغییر بدیم. دقت کنید که الان دیگه نمی‌تونیم از برگشت دادن یک مقدار ثابت استفاده کنیم، چون در اینصورت یکی از assert ها پاس نخواهد شد.

حالا اگر تست رو اجرا کنید می‌بینید که بدون مشکل پاس می‌شه.

تا اینجا سعی کردم با مثالی که گفته شد، برنامه‌نویسی به روش توسعه تست محور رو توضیح بدم. قطعا جزئیات بسیار زیادی هست که توی یک پست بلاگ نمی‌گنجه. اما خب اصول کلی TDD همین‌هایی هست که توضیح داده شد. برای مسلط شدن و تمرین بیشتر می‌تونید منبعی که در آخر معرفی کردم رو بخونید.

حالا بریم ببینیم چرا باید به این روش برنامه‌نویسی کنیم و مزایای این روش برنامه‌نویسی رو بررسی کنیم.

چرا باید به صورت TDD برنامه نویسی کنیم؟

نوشتن تست به خودی خود (بدون پیروی از TDD. یعنی نوشتن تست بعد از نوشتن کد) مزایای زیادی داره و قطعا بهتر از نداشتن تست هست. ولی توی TDD ما قبل از اینکه کدمون رو بنویسیم، تست براش می‌نویسیم که مزایای خیلی بیشتری داره.

توسعه تست محور به باگ کمتر در کد، افزایش کیفیت کد، نگهداری راحت‌تر کد، و تمرکز بیشتر منجر می‌شه.

  • باگ کمتر: وقتی به صورت TDD کد می‌نویسیم، از اونجایی که بعد از نوشتن هر تست، کل تست‌ها رو اجرا می‌کنیم، می‌تونیم مطمئن بشیم که با کد جدید، کد دیگری رو به اصطلاح break نکردیم. بررسی‌هایی که انجام شده، نشون داده شده که پیروی از TDD باعث باگ کمتر می‌شه (اینجا و اینجا رو ببینید).
  • کیفیت بیشتر کد: کیفیت کد رو معمولا با معیارهایی مثل Cohesion[۱] و Coupling[۲] و Complexity اندازه‌گیری می‌کنند. طبق مطالعات انجام شده و بر اساس معیارهایی که گفته شد، وقتی به صورت TDD برنامه‌نویسی انجام می‌دیم،‌کیفیت کد به اندازه قابل توجه‌ای بالا می‌ره (اینجا و اینجا رو ببینید).
  • نگهداری (Maintenance) راحت‌تر: وقتی باگ‌های نرم‌افزار کمتر بشن و کیفیت کد بیشتر بشه، نگهداری اون نرم‌افزار، که بخش اعظم توسعه نرم‌افزار رو تشکیل می‌ده، قاعدتا راحت‌تر می‌شه (این مقاله که تحقیق انجام شده در این زمینه هست رو ببینید). از طرفی وقتی نگهداری کد راحت‌تر بشه، پروسه Refactoring هم راحت‌تر می‌شه.
  • تمرکز بیشتر: همونطور که دیدید، وقتی یک failing test می‌نویسید، تمرکزتون روی پاس کردن اون تست هست. در هر زمان، به جای فکر کردن به کل نرم‌افزار، شما مجبورید که روی بخش خیلی کوچک‌تری تمرکز کنید. خود این موضوع هم به باگ کمتر منجر می‌شه.
هر برنامه‌نویسی، فارغ از سطح مهارت و دانشی که داره، می‌تونه از اصول TDD پیروی کنه.اگر شما یک نابغه هستید، به این اصول نیازی ندارید! اگر شما یک آدم ابله هستید، این اصول به شما کمکی نخواهند کرد! برای بقیه ما که بین این طیف هستیم (طیف نابغه تا ابله!)، برنامه‌نویسی به صورت توسعه تست محور به ما کمک می‌کنه که هر چه بیشتر به پتانسیل و توانایی‌های خودمون نزدیک‌تر بشیم.

چطوری TDD رو شروع کنیم و توش پیش بریم؟

همونطور که توضیح داده شد و دیدید، TDD یک سری اصول هست که به راحتی می‌شه از اون‌ها پیروی کرد و به این ‌‌شیوه برنامه نوشت. پس ترس نداره و بهتره زودتر استفاده از این روش رو شروع کنید ?

کتاب Test Driven Development: By Example

بهترین منبعی که من می‌شناسم، کتاب Test Driven Development: By Example نوشته Kent Beck هست که همونطور که گفته شد، توسعه دهنده این روش برنامه‌نویسی هستش. کتاب بسیار نثر روان و قابل فهمی داره و همچنین حجم خیلی کمی داره. در کل حدود ۲۴۰ صفحه است و شامل سه بخش هست.

  • بخش اول (حدود ۱۰۰ صفحه اول کتاب) شامل توضیحات اینکه TDD چی هست می‌شه که با یک مثال اون رو به خوبی توضیح می‌ده. مثال‌‌های کتاب به زبان Java هستن و ساده‌ان.
  • بخش دوم کتاب در مورد ساخت یک Framework تست هست که به نظر من نیازی به خوندنش نیست یا حداقل می‌شه روزنامه‌وار خوندش.
  • بخش سوم هم pattern ها و anti-pattern های نوشتن تست رو گفته.

سعی کنید حین خوندن کتاب مثالی که باهاش پیش می‌ره رو خودتون هم بنویسید و پیاده سازی کنید. لزومی نداره با Java بنویسید. می‌تونید با هر زبان دیگه‌ای که راحت‌ترید مثالش رو برای خودتون بنویسید.

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

مستندات تست برای هر زبان برنامه‌نویسی

بعد از اینکه کتاب رو خوندید، می‌تونید مستندات تست در زبان برنامه‌نویسی که در حال حاضر مشغول به کار با اون هستید رو مطالعه کنید تا بتونید چیزایی که یاد گرفتید رو توی پروژه‌های حاضرتون اعمال کنید. تقریبا تمام زبان‌های برنامه‌نویسی مستندات کاملی در زمینه تست دارن.




منبع اصلی من هم برای نوشتن این مطلب، همون کتاب Test Driven Development: By Example بوده.

در نهایت هم (: Happy coding

پانویس‌ها

۱- Cohesion اصطلاحیه که بیان می‌کنه یک کلاس (یا ماژول) چه میزان تک مسئولیتی است. یعنی وقتی می‌گیم یک کلاس high cohesion است یعنی اون کلاس با یک مجموعه از عملکرد (function) های مرتبط طراحی شده. Cohesion مفهوم عمومی‌تری نسبت به SRP (Single Responsibility Principle) هستش ولی هر دو با هم به صورت نزدیکی مرتبط هستن. به این معنی که کلاس‌هایی که به SRP پایبند باشن، دارای high cohesion هستن و قابلیت نگهداری بالاتری دارن.

۲- Coupling به میزان ارتباط کلاس (ماژول) ها با هم اشاره دارد. طراحی‌های loosely coupled به ما اجازه می‌دن تا سیستم‌های منعطفی بسازیم که می‌تونن تغییرات (changes) رو مدیریت کنن، چراکه وابستگی متقابل (inter-dependency) بین object ها حداقله.