unit testing: principles, practices and patterns

این فصل شامل موارد زیر است:

  • ساختار یک تست واحد
  • بهترین روش‌ها برای نام‌گذاری تست واحد
  • کار با تست‌های پارامتری
  • کار با fluent assertions

در این فصل باقی‌مانده از بخش اول، یه مرور کلی از چند موضوع پایه‌ای ارائه می‌کنم. ساختار یه تست واحد معمولی رو بررسی می‌کنم، که معمولاً با الگوی ترتیب، اجرا، و بررسی (AAA) نمایش داده می‌شه. همچنین فریم‌ورک تست واحد مورد علاقه‌م یعنی xUnit رو معرفی می‌کنم و توضیح می‌دم چرا از اون استفاده می‌کنم و نه از رقباش.

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

در نهایت، درباره‌ی چند ویژگی از این فریم‌ورک صحبت می‌کنم که به ساده‌تر شدن فرآیند تست واحد کمک می‌کنن. نگران این نباش که این اطلاعات بیش‌ازحد به C# و .NET وابسته باشه؛ بیشتر فریم‌ورک‌های تست واحد، صرف‌نظر از زبان برنامه‌نویسی، عملکرد مشابهی دارن. اگه یکی از اون‌ها رو یاد بگیری، کار با بقیه‌شون برات مشکلی ایجاد نمی‌کنه.

۳.۱ چگونه یک تست واحد را ساختاردهی کنیم

این بخش نشان می‌دهد که چگونه تست‌های واحد را با استفاده از الگوی ترتیب، اجرا، و بررسی (Arrange, Act, Assert) ساختاردهی کنیم، از چه دام‌هایی باید اجتناب کرد، و چگونه تست‌ها را تا حد امکان خوانا و قابل فهم نوشت.

۳.۱.۱ استفاده از الگوی AAA

الگوی AAA پیشنهاد می‌کنه که هر تست رو به سه بخش تقسیم کنیم: ترتیب (Arrange)، اجرا (Act)، و بررسی (Assert). (گاهی به این الگو، الگوی ۳A هم گفته می‌شه.) بیایید یه کلاس ساده به اسم Calculator رو در نظر بگیریم که یه متد برای جمع دو عدد داره:

public class Calculator
{
    public double Sum(double first, double second)
    {
        return first + second;
    }
}

قطعه‌کد زیر تستی رو نشون می‌ده که رفتار این کلاس رو بررسی می‌کنه. این تست از الگوی AAA پیروی می‌کنه.

public class CalculatorTests // کلاس—به‌عنوان یک ظرف—برای مجموعه‌ای منسجم از تست‌ها
{
    [Fact] // ویژگی مشخص کننده تست
    public void Sum_of_two_numbers() 
    {
        // Arrange
        double first = 10; 
        double second = 20; 
        var calculator = new Calculator(); 

        // Act
        double result = calculator.Sum(first, second); 

        // Assert
        Assert.Equal(30, result); 
    }
}

کد ۳.۱

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

ساختار به این صورت هست:

  • در بخش Arrange، سیستم تحت تست (SUT) و وابستگی‌هاش رو به وضعیت مورد نظر می‌رسونی.
  • در بخش Act، متدهای SUT رو صدا می‌زنی، وابستگی‌های آماده‌شده رو بهش می‌دی، و خروجی (اگه وجود داشته باشه) رو دریافت می‌کنی.
  • در بخش Assert، نتیجه رو بررسی می‌کنی.
    نتیجه ممکنه به‌صورت مقدار بازگشتی، وضعیت نهایی SUT و همکارهاش، یا متدهایی که SUT روی اون همکارها صدا زده نمایش داده بشه.

الگوی Given-When-Then

شاید با الگوی Given-When-Then آشنا باشید، که مشابه AAA است.
این الگو هم پیشنهاد می‌کند که تست به سه بخش تقسیم شود:

  • Given — معادل بخش arrange
  • When — معادل بخش act
  • Then — معادل بخش assert

از نظر ساختار تست، بین این دو الگو تفاوتی وجود ندارد.
تنها تفاوت این است که ساختار Given-When-Then برای افراد غیر‌برنامه‌نویس خواناتر است. بنابراین، Given-When-Then برای تست‌هایی که با افراد غیر‌فنی به اشتراک گذاشته می‌شوند، مناسب‌تر است.

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

چنین روشی ممکنه در نگاه اول غیرمنطقی به نظر برسه، اما در واقع همون روشی‌یه که ما برای حل مسئله به کار می‌بریم. ما با فکر کردن به هدف شروع می‌کنیم: این‌که یه رفتار خاص قراره چه کاری برای ما انجام بده. حل واقعی مسئله بعد از اون میاد. نوشتن بخش assert قبل از هر چیز دیگه، فقط یه formalization از همین فرآیند فکریه. اما دوباره تأکید می‌کنم: این راهنما فقط زمانی کاربرد داره که از TDD پیروی می‌کنی – یعنی وقتی تست رو قبل از کد تولیدی می‌نویسی. اگه کد تولیدی (production) رو قبل از تست بنویسی، وقتی به سراغ نوشتن تست بری، دیگه می‌دونی از اون رفتار چه انتظاری داری، پس شروع کردن با بخش arrange انتخاب بهتریه.

۳.۱.۲ از چندین بخش arrange، act، و assert در یک تست خودداری کنید

گاهی ممکنه با تستی مواجه بشید که چندین بخش arrange، act، یا assert داره. این نوع تست معمولاً به شکلی مشابه شکل ۳.۱ عمل می‌کنه. وقتی چندین بخش act رو می‌بینید که با assert و احتمالاً arrange از هم جدا شدن، یعنی اون تست داره چند واحد رفتار مختلف رو بررسی می‌کنه. و همون‌طور که در فصل ۲ گفتیم، چنین تستی دیگه تست واحد نیست، بلکه یه تست یکپارچه (integration test) محسوب می‌شه.

شکل ۳.۱: وجود چندین بخش arrange، act، و assert نشانه‌ایه از این‌که تست داره چند چیز رو هم‌زمان بررسی می‌کنه. برای رفع این مشکل، چنین تستی باید به چند تست جداگانه تقسیم بشه.

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

گاهی داشتن چندین بخش act در تست‌های یکپارچه (integration tests) قابل قبول است. همون‌طور که از فصل قبل یادت هست، تست‌های یکپارچه ممکنه کند باشن. یکی از راه‌های سریع‌تر کردن اون‌ها اینه که چند تست یکپارچه رو در قالب یه تست با چندین act و assert گروه‌بندی کنیم. این روش به‌ویژه زمانی مفیده که وضعیت‌های سیستم به‌صورت طبیعی از تست‌ها عبور می‌کنه – یعنی وقتی یه act به‌طور هم‌زمان نقش arrange برای act بعدی رو هم ایفا می‌کنه.

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

۳.۱.۳ از استفاده‌ی if در تست‌ها خودداری کنید

مشابه با مواردی که چندین بخش arrange، act، و assert در تست دیده می‌شه، گاهی ممکنه در تست واحد با یک دستور if مواجه بشید. این هم یک ضد الگو (anti-pattern) محسوب می‌شه. تست – چه تست واحد باشه و چه تست یکپارچه – باید یه دنباله‌ی ساده از مراحل باشه بدون هیچ‌گونه انشعاب (branching).

وجود دستور if نشون می‌ده که تست داره چند چیز رو هم‌زمان بررسی می‌کنه. بنابراین، چنین تستی باید به چند تست جداگانه تقسیم بشه. اما برخلاف وضعیت چندین بخش AAA، در این‌جا هیچ استثنایی برای تست‌های یکپارچه وجود نداره. انشعاب در تست هیچ مزیتی نداره. فقط باعث افزایش هزینه‌ی نگه‌داری می‌شه: دستورهای if باعث می‌شن تست‌ها سخت‌تر خونده و درک بشن.

۳.۱.۴ اندازه‌ی مناسب هر بخش چقدر باید باشه؟

یکی از سوال‌های رایجی که افراد هنگام شروع کار با الگوی AAA می‌پرسن اینه که: هر بخش از تست باید چقدر بزرگ باشه؟ و همچنین، بخش teardown – یعنی بخشی که بعد از تست عملیات پاک‌سازی انجام می‌ده – چه جایگاهی داره؟ برای اندازه‌ی هر بخش در تست، راهنماهای مختلفی وجود داره. در ادامه، به اصول کلی و توصیه‌شده برای هر بخش می‌پردازیم.

بخش Arrange معمولاً بزرگ‌ترین بخش تست است

در اغلب موارد، بخش Arrange بزرگ‌ترین قسمت از سه بخش تست است. این بخش می‌تونه به‌اندازه‌ی مجموع بخش‌های Act و Assert باشه—و این کاملاً طبیعیه. اما اگه بخش Arrange به‌طور قابل توجهی بزرگ‌تر از این حد بشه، بهتره اون رو به یکی از دو روش زیر بازسازی (refactor) کنی:

  • استخراج کدهای آماده‌سازی به متدهای خصوصی (private methods) درون همون کلاس تست
  • یا انتقال اون‌ها به یک کلاس مستقل مثل یک factory class

برای استفاده مجدد از کدهای بخش Arrange، دو الگوی محبوب وجود دارن:

  • Object Mother
  • Test Data Builder

این الگوها کمک می‌کنن تا داده‌های تستی به‌صورت منسجم، قابل‌خواندن، و قابل‌توسعه تولید بشن.

مراقب Actهایی باشید که بیشتر از یک خط هستند

بخش Act معمولاً فقط یه خط کد داره. اگه این بخش شامل دو یا چند خط بشه، ممکنه نشونه‌ای باشه از این‌که رابط عمومی (public API) سیستم تحت تست (SUT) مشکل داره.

برای روشن‌تر شدن این نکته، بهتره با یه مثال توضیح بدیم – مثالی از فصل ۲ که در ادامه دوباره آورده شده: در این مثال، یه مشتری از فروشگاه خرید می‌کنه.

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(store, Product.Shampoo, 5);

    // Assert
    Assert.True(success);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

کد ۳.۲

توجه کن که در تست قبلی، بخش Act فقط شامل یک فراخوانی متد بود – و این نشونه‌ایه از طراحی خوب در API کلاس مورد نظر. حالا اونو با نسخه‌ی موجود در لیست ۳.۳ مقایسه کن: در اون تست، بخش Act شامل دو خط هست. و این یه نشونه‌ست از وجود مشکل در سیستم تحت تست (SUT): سیستم از کلاینت انتظار داره که خودش یادش باشه برای کامل کردن عملیات خرید، متد دوم رو هم صدا بزنه. و این یعنی عدم وجود کپسوله‌سازی (encapsulation).

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(store, Product.Shampoo, 5);
    store.RemoveInventory(success, Product.Shampoo, 5);

    // Assert
    Assert.True(success);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

کد ۳.۳

چیزی که از بخش act در لیست ۳.۳ می‌شه برداشت کرد:

  • در خط اول، مشتری تلاش می‌کنه پنج واحد شامپو از فروشگاه بخره.
  • در خط دوم، موجودی از فروشگاه حذف می‌شه. این حذف فقط در صورتی انجام می‌شه که فراخوانی قبلی به Purchase() موفقیت‌آمیز بوده باشه.

مشکل نسخه‌ی جدید اینه که برای انجام یک عملیات، به دو فراخوانی متد نیاز داره. توجه داشته باش که این مشکل مربوط به خود تست نیست. تست همچنان داره همون واحد رفتاری رو بررسی می‌کنه: فرآیند خرید. مشکل در سطح API کلاس Customer قرار داره. این کلاس نباید از کلاینت بخواد که یه فراخوانی اضافی انجام بده.

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

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

راه‌حل اینه که همیشه کپسوله‌سازی (encapsulation) کد رو حفظ کنیم. در مثال قبلی، متد Purchase باید خودش موجودی خریداری‌شده رو از فروشگاه کم کنه و نباید به کد کلاینت برای انجام این کار وابسته باشه. وقتی صحبت از حفظ وضعیت‌های پایدار (invariants) می‌شه، باید هر مسیر احتمالی‌ای که ممکنه منجر به نقض اون وضعیت‌ها بشه رو حذف کنیم.

این راهنما – یعنی محدود نگه‌داشتن بخش act به یک خط – در بیشتر کدهایی که منطق تجاری دارن صدق می‌کنه، اما در مورد کدهای ابزاری یا زیرساختی کمتر صدق می‌کنه. پس نمی‌گم «هیچ‌وقت این کار رو نکن»، اما حتماً هر مورد رو بررسی کن تا مطمئن بشی که کپسوله‌سازی نقض نشده.

۳.۱.۵ بخش Assert باید چند تا بررسی داشته باشه؟

در نهایت می‌رسیم به بخش Assert. شاید شنیده باشی که یه راهنما وجود داره که می‌گه «هر تست فقط باید یه assert داشته باشه». این راهنما ریشه در یه فرض داره که در فصل قبل بررسی شد: فرض تمرکز روی کوچک‌ترین قطعه‌ی ممکن از کد.

اما همون‌طور که قبلاً دیدی، این فرض اشتباهه. در تست واحد، «واحد» به معنی یه واحد رفتاری هست، نه یه واحد کدی. یه واحد رفتاری ممکنه چندین خروجی داشته باشه، و بررسی همه‌ی اون‌ها در یک تست کاملاً قابل قبوله.

با این حال، باید مراقب باشی که بخش Assert خیلی بزرگ نشه – چون ممکنه نشونه‌ای باشه از نبود یه انتزاع مناسب در کد اصلی. برای مثال، به‌جای بررسی تک‌تک ویژگی‌های یه شیء که از SUT برگشته، ممکنه بهتر باشه که توی کلاس اون شیء، اعضای برابری (equality members) مناسبی تعریف بشن. اون‌وقت می‌تونی با یه assert ساده، شیء رو با مقدار مورد انتظار مقایسه کنی.

۳.۱.۶ فاز Teardown چه جایگاهی دارد؟

برخی افراد یه بخش چهارم به نام teardown رو هم در نظر می‌گیرن، که بعد از بخش‌های arrange، act، و assert قرار می‌گیره. برای مثال، می‌تونی از این بخش برای حذف فایل‌هایی که تست ایجاد کرده، بستن اتصال دیتابیس، و موارد مشابه استفاده کنی. بخش teardown معمولاً به‌صورت یه متد جداگانه تعریف می‌شه و در همه‌ی تست‌های اون کلاس قابل استفاده‌ست. به همین دلیل، من این فاز رو جزو الگوی AAA حساب نمی‌کنم.

توجه داشته باش که بیشتر تست‌های واحد نیازی به teardown ندارن. تست‌های واحد با وابستگی‌های خارج از پردازش (out-of-process) ارتباطی ندارن، و بنابراین اثر جانبی‌ای باقی نمی‌ذارن که نیاز به پاک‌سازی داشته باشه. این موضوع بیشتر مربوط به تست‌های یکپارچه‌ست. در بخش سوم کتاب، بیشتر درباره‌ی نحوه‌ی پاک‌سازی مناسب بعد از تست‌های یکپارچه صحبت خواهیم کرد.

۳.۱.۷ تشخیص سیستم تحت تست (SUT)

سیستم تحت تست (SUT) نقش مهمی در تست‌ها داره. این سیستم نقطه‌ی ورود رفتاریه که می‌خوای در برنامه فراخوانی کنی. همون‌طور که در فصل قبل گفتیم، این رفتار می‌تونه در حد چند کلاس گسترده باشه یا فقط یه متد ساده باشه – اما همیشه فقط یک نقطه‌ی ورود وجود داره: یه کلاس که اون رفتار رو فعال می‌کنه.

بنابراین، خیلی مهمه که SUT رو از وابستگی‌هاش جدا تشخیص بدی، به‌خصوص وقتی تعداد وابستگی‌ها زیاد باشه، تا مجبور نشی وقت زیادی صرف تشخیص نقش هر شیء در تست کنی. برای این کار، همیشه در تست‌ها نام SUT رو sut بذار. در قطعه کد بعدی، می‌بینی که چطور تست‌های Calculator بعد از تغییر نام نمونه‌ی Calculator به sut ظاهر می‌شن.

public class CalculatorTests
{
    [Fact]
    public void Sum_of_two_numbers()
    {
        // Arrange
        double first = 10;
        double second = 20;
        var sut = new Calculator(); // the calculator is now called sut.

        // Act
        double result = sut.Sum(first, second);

        // Assert
        Assert.Equal(30, result);
    }
}

کد ۳.۴ – مشخص کردن سیستم تحت تست با نامگذاری sut

۳.۱.۸ حذف کامنت‌های arrange، act، و assert از تست‌ها

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

public class CalculatorTests
{
    [Fact]
    public void Sum_of_two_numbers()
    {
        double first = 10; 
        double second = 20; 
        var sut = new Calculator(); 

        double result = sut.Sum(first, second); 

        Assert.Equal(30, result); 
    }
}

کد ۳.۵ – قسمت‌های مختلف تست با خط خالی از همدیگه جدا شدن

جدا کردن بخش‌ها با خطوط خالی در بیشتر تست‌های واحد خیلی خوب جواب می‌ده. این کار باعث می‌شه بین اختصار و خوانایی تعادل برقرار بشه. اما در تست‌های بزرگ، این روش چندان مؤثر نیست – چون ممکنه بخوای داخل بخش arrange چند خط خالی بذاری تا مراحل مختلف پیکربندی رو از هم جدا کنی. این موضوع معمولاً در تست‌های یکپارچه (integration tests) پیش میاد، چون این تست‌ها معمولاً منطق راه‌اندازی پیچیده‌ای دارن.

بنابراین:

  • در تست‌هایی که از الگوی AAA پیروی می‌کنن و نیاز به خطوط خالی اضافی در بخش‌های arrange و assert نداری، کامنت‌های بخش‌ها رو حذف کن.
  • در غیر این صورت، کامنت‌های بخش‌ها رو نگه دار.

۳.۲ بررسی فریم‌ورک تست xUnit

در این بخش، یه مرور کوتاه داریم بر ابزارهای تست واحد موجود در .NET و ویژگی‌هاشون. من از فریم‌ورک xUnit برای تست واحد استفاده می‌کنم. توجه داشته باش که برای اجرای تست‌های xUnit در ویژوال استودیو، باید پکیج NuGet به نام xunit.runner.visualstudio رو نصب کنی. اگرچه این فریم‌ورک فقط در محیط .NET کار می‌کنه، اما تقریباً همه‌ی زبان‌های شی‌ءگرا مثل Java، C++، JavaScript و غیره فریم‌ورک‌های تست واحد خودشون رو دارن – و همه‌ی اون‌ها از نظر ساختار و ظاهر خیلی شبیه به هم هستن. اگه با یکی از اون‌ها کار کرده باشی، احتمالاً مشکلی برای کار با بقیه نخواهی داشت.

در خود .NET چند گزینه‌ی جایگزین برای تست واحد وجود داره، مثل NUnit و ابزار داخلی Microsoft MSTest. من شخصاً xUnit رو ترجیح می‌دم – دلایلش رو به‌زودی توضیح می‌دم – اما می‌تونی از NUnit هم استفاده کنی؛ این دو فریم‌ورک از نظر امکانات تقریباً در یک سطح هستن. اما MSTest رو توصیه نمی‌کنم؛ چون انعطاف‌پذیری‌ای که xUnit و NUnit ارائه می‌دن رو نداره. و این فقط نظر شخصی من نیست – حتی بعضی از افراد داخل خود مایکروسافت هم از MSTest استفاده نمی‌کنن. برای مثال، تیم ASP.NET Core از xUnit استفاده می‌کنه.

من xUnit رو ترجیح می‌دم چون نسخه‌ای ساده‌تر و مختصرتر از NUnit هست. برای مثال، شاید متوجه شده باشی که در تست‌هایی که تا حالا دیدی، هیچ ویژگی (attribute) مرتبط با فریم‌ورک وجود نداره جز [Fact] – که متد رو به‌عنوان تست واحد علامت‌گذاری می‌کنه تا فریم‌ورک تست بتونه اون رو اجرا کنه. هیچ ویژگی‌ای مثل [TestFixture] وجود نداره؛ هر کلاس عمومی می‌تونه شامل تست واحد باشه. همچنین خبری از [SetUp] یا [TearDown] نیست. اگه بخوای منطق پیکربندی رو بین تست‌ها به اشتراک بذاری، می‌تونی اون رو داخل سازنده (constructor) قرار بدی. و اگه نیاز به پاک‌سازی چیزی داشته باشی، می‌تونی رابط IDisposable رو پیاده‌سازی کنی – همون‌طور که در لیست بعدی نشون داده شده.

public class CalculatorTests : IDisposable
{
    private readonly Calculator _sut;

    public CalculatorTests() // قبل از اجرای هر تست داخل این کلاس فراخوانی میشه
    { 
        _sut = new Calculator(); 
    } 

    [Fact]
    public void Sum_of_two_numbers()
    {
        /* ... */
    }

    public void Dispose() // بعد از اجرای هر تست داخل این کلاس فراخوانی میشه
    { 
        _sut.CleanUp(); 
    } 
}

نویسندگان xUnit قدم‌های مهمی برای ساده‌سازی این فریم‌ورک برداشتن. خیلی از مفاهیمی که قبلاً نیاز به پیکربندی اضافه داشتن – مثل ویژگی‌های [TestFixture] یا [SetUp] – الان به کمک قراردادها و ساختارهای داخلی زبان پیاده‌سازی می‌شن.

من به‌خصوص ویژگی [Fact] رو دوست دارم، دقیقاً به این دلیل که اسمش «Fact» هست و نه «Test». این اسم تأکید می‌کنه بر اون قاعده‌ی کلی که در فصل قبل گفتیم: هر تست باید یه داستان تعریف کنه. این داستان یه سناریوی مستقل، اتمی، و واقعی از دامنه‌ی مسئله‌ست، و موفق بودن تست، اثباتی‌ست بر این‌که اون سناریو یا واقعیت همچنان برقرار هست. اگه تست شکست بخوره، یعنی یا اون داستان دیگه معتبر نیست و باید بازنویسی بشه، یا خود سیستم نیاز به اصلاح داره.

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

۳.۳ استفاده‌ی مجدد از پیکربندی تست‌ها (Test Fixtures)

مهمه که بدونی چه زمانی و چطور باید کد رو بین تست‌ها به اشتراک بذاری. استفاده‌ی مجدد از کدهای بخش arrange، راه خوبی برای کوتاه‌تر و ساده‌تر کردن تست‌هاست – و این بخش نشون می‌ده که چطور این کار رو به‌درستی انجام بدی.

قبلاً اشاره کردیم که پیکربندی‌های اولیه اغلب فضای زیادی از تست رو اشغال می‌کنن. در چنین مواردی، منطقیه که این پیکربندی‌ها رو به متدها یا کلاس‌های جداگانه منتقل کنی و بعد اون‌ها رو بین تست‌ها به اشتراک بذاری. دو روش برای این نوع استفاده‌ی مجدد وجود داره – اما فقط یکی از اون‌ها مفیده. روش دیگه باعث افزایش هزینه‌ی نگهداری می‌شه.

پیکربندی تست (Test Fixture)

اصطلاح test fixture دو معنی رایج داره:

  1. شیئی که تست روی اون اجرا می‌شه.
    این شیء می‌تونه یه وابستگی معمولی باشه—مثلاً آرگومانی که به SUT پاس داده می‌شه. همچنین می‌تونه داده‌ای در دیتابیس یا فایلی روی دیسک باشه. چنین شیئی باید قبل از هر اجرای تست، در یه وضعیت مشخص و ثابت قرار داشته باشه تا نتیجه‌ی تست همیشه یکسان باشه. به همین دلیل بهش می‌گن fixture (یعنی ثابت و پایدار).
  2. تعریف دوم از فریم‌ورک NUnit میاد.
    در NUnit، TestFixture یه ویژگی (attribute) هست
    که کلاس حاوی تست‌ها رو علامت‌گذاری می‌کنه.

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

اولین روش – که اشتباهه – اینه که پیکربندی تست (test fixture) رو در سازنده‌ی کلاس تست یا در متدی مثل [SetUp] (در NUnit) مقداردهی اولیه کنی.

public class CustomerTests
{
    private readonly Store _store; 
    private readonly Customer _sut;

    public CustomerTests() 
    { 
        // موارد زیر قبل از هر تست اجرا میشن
        _store = new Store(); 
        _store.AddInventory(Product.Shampoo, 10); 
        _sut = new Customer(); 
    } 

    [Fact]
    public void Purchase_succeeds_when_enough_inventory()
    {
        bool success = _sut.Purchase(_store, Product.Shampoo, 5);
        Assert.True(success);
        Assert.Equal(5, _store.GetInventory(Product.Shampoo));
    }

    [Fact]
    public void Purchase_fails_when_not_enough_inventory()
    {
        bool success = _sut.Purchase(_store, Product.Shampoo, 15);
        Assert.False(success);
        Assert.Equal(10, _store.GetInventory(Product.Shampoo));
    }
}

کد ۳.۷ – استخراج کد آماده سازی از تست و انتقال به تابع سازنده

دو تستی که در لیست ۳.۷ اومده‌ن، منطق پیکربندی مشترکی دارن. در واقع، بخش arrange در هر دو تست یکسانه و به همین دلیل می‌شه اون رو به‌طور کامل به سازنده‌ی کلاس CustomerTests منتقل کرد – که دقیقاً همین کاریه که اینجا انجام دادم.

با این روش، می‌تونی حجم زیادی از کد تست رو کاهش بدی – و حتی ممکنه بتونی بیشتر یا تمام پیکربندی‌های fixture رو از خود تست‌ها حذف کنی. اما این تکنیک دو ایراد مهم داره:

  • وابستگی زیاد بین تست‌ها ایجاد می‌کنه.
  • خوانایی تست‌ها رو کاهش می‌ده.

بیایم این ایرادها رو دقیق‌تر بررسی کنیم.

۳.۳.۱ وابستگی زیاد بین تست‌ها یک ضدالگو است

در نسخه‌ی جدیدی که در لیست ۳.۷ دیدیم، همه‌ی تست‌ها به همدیگه وابسته‌ان:
تغییر منطق پیکربندی یکی از تست‌ها روی بقیه‌ی تست‌های کلاس تأثیر می‌ذاره. برای مثال، تغییر این خط:

_store.AddInventory(Product.Shampoo, 10);

به این:

_store.AddInventory(Product.Shampoo, 15);

فرض اولیه‌ای که تست‌ها درباره‌ی وضعیت فروشگاه دارن رو نقض می‌کنه، و باعث شکست‌های غیرضروری در تست‌ها می‌شه. این نقض یه اصل مهمه: تغییر در یک تست نباید روی تست‌های دیگه تأثیر بذاره.

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

برای رعایت این اصل، باید از ایجاد وضعیت مشترک در کلاس تست خودداری کنی. این دو فیلد خصوصی نمونه‌هایی از چنین وضعیت مشترکی هستن:

private readonly Store _store;
private readonly Customer _sut;

۳.۳.۲ استفاده از سازنده در تست‌ها خوانایی تست را کاهش می‌دهد

ایراد دیگه‌ی انتقال منطق پیکربندی به سازنده‌ی کلاس تست، کاهش خوانایی تست‌هاست. دیگه نمی‌تونی با نگاه کردن به خود تست، تصویر کامل رو ببینی؛ باید بخش‌های مختلف کلاس رو بررسی کنی تا بفهمی متد تست دقیقاً چی کار می‌کنه.

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

۳.۳.۳ روش بهتر برای استفاده‌ی مجدد از پیکربندی تست‌ها

استفاده از سازنده، بهترین راه برای استفاده‌ی مجدد از fixtureها نیست. روش دوم – که مفید و توصیه‌شده‌ست – اینه که در کلاس تست، متدهای کارخانه‌ای خصوصی (private factory methods) تعریف کنی. لیست بعدی این روش رو نشون می‌ده.

public class CustomerTests
{
    [Fact]
    public void Purchase_succeeds_when_enough_inventory()
    {
        Store store = CreateStoreWithInventory(Product.Shampoo, 10);
        Customer sut = CreateCustomer();
        bool success = sut.Purchase(store, Product.Shampoo, 5);
        Assert.True(success);
        Assert.Equal(5, store.GetInventory(Product.Shampoo));
    }

    [Fact]
    public void Purchase_fails_when_not_enough_inventory()
    {
        Store store = CreateStoreWithInventory(Product.Shampoo, 10);
        Customer sut = CreateCustomer();
        bool success = sut.Purchase(store, Product.Shampoo, 15);
        Assert.False(success);
        Assert.Equal(10, store.GetInventory(Product.Shampoo));
    }

    private Store CreateStoreWithInventory(Product product, int quantity)
    {
        Store store = new Store();
        store.AddInventory(product, quantity);
        return store;
    }

    private static Customer CreateCustomer()
    {
        return new Customer();
    }
}

کد ۳.۸ – انتقال پیکربندی‌های عمومی به factory خصوصی

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

برای مثال، به این خط نگاه کن:

Store store = CreateStoreWithInventory(Product.Shampoo, 10);

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

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

یه استثنا برای این قانون وجود داره: می‌تونی fixture رو در سازنده مقداردهی کنی، اگه توسط همه یا تقریباً همه‌ی تست‌ها استفاده بشه. این معمولاً در تست‌های یکپارچه (integration tests) که با پایگاه داده کار می‌کنن صدق می‌کنه. همه‌ی این تست‌ها به اتصال پایگاه داده نیاز دارن، که می‌تونی یه بار مقداردهی‌ش کنی و بعد در همه‌جا استفاده‌ش کنی. اما حتی در این حالت هم، بهتره یه کلاس پایه تعریف کنی و اتصال پایگاه داده رو در سازنده‌ی اون کلاس مقداردهی کنی – نه در کلاس‌های تست جداگانه.

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

public class CustomerTests : IntegrationTests
{
    [Fact]
    public void Purchase_succeeds_when_enough_inventory()
    {
        /* use _database here */
    }
}

public abstract class IntegrationTests : IDisposable
{
    protected readonly Database _database;

    protected IntegrationTests()
    {
        _database = new Database();
    }

    public void Dispose()
    {
        _database.Dispose();
    }
}

کد ۳.۹ – انتقال پیکربندی عمومی به کلاس پایه

توجه کن که کلاس CustomerTests بدون سازنده باقی مونده. این کلاس از طریق ارث‌بری از کلاس پایه‌ی IntegrationTests به نمونه‌ی دیتابیس دسترسی پیدا می‌کنه.

۳.۴ نام‌گذاری تست واحد

نام‌ گذاری گویا و دقیق برای تست‌ها اهمیت زیادی داره. نام‌گذاری درست کمک می‌کنه بفهمی تست دقیقاً چی رو بررسی می‌کنه و سیستم زیرساختی چطور رفتار می‌کنه.

خب، تست واحد رو چطور باید نام‌گذاری کرد؟ در طول یک دهه‌ی گذشته، سبک‌های مختلفی رو دیدم و امتحان کردم. یکی از رایج‌ترین سبک‌ها – و شاید کم‌فایده‌ترین‌ها – این قالبه:

[MethodUnderTest]_[Scenario]_[ExpectedResult]

که در اون:

  • MethodUnderTest: نام متدی هست که داری تستش می‌کنی
  • Scenario: شرایطی که تحت اون متد رو تست می‌کنی
  • ExpectedResult: نتیجه‌ای که انتظار داری متد در اون شرایط تولید کنه

این سبک نام‌گذاری کم‌فایده‌ست چون تمرکزت رو می‌بره سمت جزئیات پیاده‌سازی، نه رفتار سیستم.

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

برای نمونه، بیایم دوباره نگاهی بندازیم به تستی که در لیست ۳.۵ اومده بود.

public class CalculatorTests
{
    [Fact]
    public void Sum_of_two_numbers()
    {
        double first = 10;
        double second = 20;
        var sut = new Calculator();

        double result = sut.Sum(first, second);

        Assert.Equal(30, result);
    }
}

نام تست Sum_of_two_numbers رو می‌شه طبق الگوی [MethodUnderTest]_[Scenario]_[ExpectedResult] به این صورت بازنویسی کرد:

public void Sum_TwoNumbers_ReturnsSum()

در اینجا:

  • متد مورد تست: Sum
  • سناریو: وجود دو عدد
  • نتیجه‌ی مورد انتظار: حاصل جمع اون دو عدد

این نام از نظر یک برنامه‌نویس منطقی به‌نظر می‌رسه، اما آیا واقعاً به خوانایی تست کمک می‌کنه؟ اصلاً نه. برای کسی که با سیستم آشنا نیست، این اسم مثل زبان یونانیه. فکر کن: چرا Sum دوبار در نام تست تکرار شده؟ اصلاً این عبارت Returns یعنی چی؟ جمع به کجا «برگردونده» می‌شه؟نمی‌تونی بدونی.

بعضی‌ها ممکنه بگن که اصلاً مهم نیست یه غیر‌برنامه‌نویس درباره‌ی این اسم چی فکر می‌کنه. در نهایت، تست‌های واحد رو برنامه‌نویس‌ها برای برنامه‌نویس‌ها می‌نویسن، نه برای متخصص‌های دامنه. و برنامه‌نویس‌ها هم توی رمزگشایی از اسم‌های مبهم مهارت دارن—این بخشی از کارشونه!

درسته، اما فقط تا حدی. اسم‌های مبهم برای همه – چه برنامه‌نویس، چه غیر‌برنامه‌نویس – بار ذهنی ایجاد می‌کنن. برای فهمیدن اینکه تست دقیقاً چی رو بررسی می‌کنه و چطور به نیازهای کسب‌وکار مربوطه، باید ظرفیت ذهنی بیشتری صرف بشه. شاید در ظاهر مهم نباشه، اما این فشار ذهنی به‌مرور زمان جمع می‌شه و هزینه‌ی نگهداری کل مجموعه‌ی تست رو بالا می‌بره. این موضوع مخصوصاً وقتی محسوسه که بعد از مدتی برگردی سراغ تست و جزئیات اون ویژگی رو فراموش کرده باشی، یا بخوای تستی رو که همکارت نوشته بفهمی. خوندن کد دیگران خودش به‌اندازه‌ی کافی سخت هست – هر کمکی برای فهم بهتر، ارزشمنده.

دوباره نگاهی بندازیم به دو نسخه‌ی نام‌گذاری:

public void Sum_of_two_numbers()
public void Sum_TwoNumbers_ReturnsSum()

اون اسم اول که به زبان ساده نوشته شده، خیلی راحت‌تر خونده می‌شه. یه توصیف ساده و زمینی از رفتاریه که داره تست می‌شه.

۳.۴.۱ راهنمای نام‌گذاری تست واحد:

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

  • از یک سیاست نام‌گذاری خشک و قالبی پیروی نکن.
    نمی‌تونی یه توصیف سطح‌بالا از یه رفتار پیچیده رو توی قالب محدود چنین سیاستی جا بدی.
    اجازه بده بیان آزاد باشه.
  • تست رو طوری نام‌گذاری کن که انگار داری سناریو رو برای یه غیر‌برنامه‌نویس آشنا با دامنه‌ی مسئله توضیح می‌دی. مثلاً یه متخصص دامنه یا تحلیل‌گر کسب‌وکار.
  • کلمات رو با زیرخط (_) از هم جدا کن.
    این کار به‌ویژه در نام‌های طولانی، خوانایی رو بالا می‌بره.

توجه کن که هنگام نام‌گذاری کلاس تست CalculatorTests از زیرخط (underscore) استفاده نکردم. معمولاً نام کلاس‌ها طولانی نیست، بنابراین بدون زیرخط هم خوانا هستن.

همچنین دقت کن که اگرچه از الگوی [ClassName]Tests برای نام‌گذاری کلاس‌های تست استفاده می‌کنم، اما این به این معنی نیست که تست‌ها فقط محدود به بررسی همون کلاس هستن. یادت باشه که «واحد» در تست واحد، یعنی واحد رفتار – نه کلاس. این واحد می‌تونه شامل یک یا چند کلاس باشه؛ اندازه‌ی واقعی اون مهم نیست. با این حال، باید از یه نقطه شروع کرد. کلاسی که در [ClassName]Tests میاد، فقط یه نقطه‌ی وروده – یه API که از طریقش می‌تونی یه واحد رفتار رو تست کنی.

۳.۴.۲ مثال: تغییر نام یک تست بر اساس دستورالعمل‌ها

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

در کد زیر، می‌تونی تستی رو ببینی که بررسی می‌کنه یک تحویل (delivery) با تاریخ گذشته نامعتبره. نام این تست با استفاده از یک سیاست نام‌گذاری خشک نوشته شده که کمکی به خوانایی تست نمی‌کنه.

[Fact]
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
{
    DeliveryService sut = new DeliveryService();
    DateTime pastDate = DateTime.Now.AddDays(-1);
    Delivery delivery = new Delivery
    {
        Date = pastDate
    };
    bool isValid = sut.IsDeliveryValid(delivery);
    Assert.False(isValid);
}

کد ۳.۱۰

این تست بررسی می‌کنه که DeliveryService به‌درستی یک تحویل با تاریخ نادرست رو نامعتبر تشخیص بده.
چطور می‌شه نام تست رو به زبان ساده بازنویسی کرد؟ یک تلاش اولیه خوب اینه:

public void Delivery_with_invalid_date_should_be_considered_invalid()

به دو نکته در نسخه‌ی جدید توجه کن:

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

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

نام متد تحت تست در نام تست

نام متد SUT رو در نام تست قرار نده.
یادت باشه که تو کد رو تست نمی‌کنی، بلکه رفتار برنامه رو تست می‌کنی. بنابراین مهم نیست نام متدی که تحت تست قرار گرفته چی باشه. همون‌طور که قبلاً گفتم، SUT فقط یه نقطه‌ی ورود برای فراخوانی رفتار محسوب می‌شه. می‌تونی نام متد تحت تست رو مثلاً به IsDeliveryCorrect تغییر بدی و هیچ تأثیری روی رفتار SUT نداره.

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

تنها استثنا برای این دستورالعمل زمانی‌ست که روی کدهای کمکی (utility code) کار می‌کنی. چنین کدی منطق تجاری نداره—رفتارش فراتر از عملکردهای جانبی ساده نمی‌ره و بنابراین برای افراد کسب‌وکار معنایی نداره. در این موارد استفاده از نام متدهای SUT در نام تست اشکالی نداره.

اما برگردیم به مثال. نسخه‌ی جدید نام تست شروع خوبی بود، ولی می‌شه بیشتر بهبودش داد. تحویل با تاریخ نامعتبر دقیقاً یعنی چی؟ از تست در کد ۳.۱۰ می‌بینیم که تاریخ نامعتبر هر تاریخی در گذشته‌ست. این منطقیه—باید فقط اجازه داشته باشی تاریخ تحویل رو در آینده انتخاب کنی.

پس بیاییم مشخص‌تر باشیم و این دانش رو در نام تست منعکس کنیم:

public void Delivery_with_past_date_should_be_considered_invalid()

این بهتره ولی هنوز ایده‌آل نیست. خیلی طولانیه. می‌تونیم کلمه‌ی considered رو حذف کنیم بدون اینکه معنایی از دست بره:

public void Delivery_with_past_date_should_be_invalid()

عبارت should be هم یک ضد‌الگوی رایجه. همون‌طور که قبلاً در این فصل اشاره کردم، تست یک حقیقت واحد و اتمی درباره‌ی یک رفتار خاصه. جایی برای آرزو یا خواسته در بیان یک حقیقت وجود نداره. بنابراین نام تست رو مطابق این اصل تغییر بده—should be رو با is جایگزین کن:

public void Delivery_with_past_date_is_invalid()

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

public void Delivery_with_a_past_date_is_invalid()

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

۳.۵ بازآرایی به تست‌های پارامتری

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

خوشبختانه بیشتر فریم‌ورک‌های تست واحد قابلیتی فراهم می‌کنن که اجازه می‌ده تست‌های مشابه رو با استفاده از تست‌های پارامتری گروه‌بندی کنی (شکل ۳.۲ رو ببین).

شکل ۳.۲ یک برنامه‌ی معمولی چندین رفتار رو نمایش می‌ده. هرچه پیچیدگی رفتار بیشتر باشه، حقایق بیشتری برای توصیف کامل اون لازم می‌شه. هر حقیقت با یک تست نمایش داده می‌شه. حقایق مشابه رو می‌شه در یک متد تست واحد با استفاده از تست‌های پارامتری گروه‌بندی کرد.

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

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

تست موجود با نام Delivery_with_a_past_date_is_invalid تعریف شده. می‌تونیم سه تست دیگه اضافه کنیم:

public void Delivery_for_today_is_invalid()
public void Delivery_for_tomorrow_is_invalid()
public void The_soonest_delivery_date_is_two_days_from_now()

اما این کار منجر به چهار متد تست می‌شه که تنها تفاوتشون تاریخ تحویله. روش بهتر اینه که این تست‌ها رو در یک تست گروه‌بندی کنیم تا حجم کد تست کاهش پیدا کنه. xUnit (مثل بیشتر فریم‌ورک‌های تست دیگه) قابلیتی به نام تست‌های پارامتری داره که دقیقاً همین کار رو ممکن می‌کنه. کد بعدی نشون می‌ده این گروه‌بندی چطور به‌نظر می‌رسه. هر ویژگی InlineData یک حقیقت جداگانه درباره‌ی سیستم رو نمایش می‌ده؛ یعنی خودش یک مورد تست مستقل محسوب می‌شه.

public class DeliveryServiceTests
{
    [InlineData(-1, false)] 
    [InlineData(0, false)] 
    [InlineData(1, false)] 
    [InlineData(2, true)] 
    [Theory]
    public void Can_detect_an_invalid_delivery_date(
        int daysFromNow,
        bool expected)
    {
        DeliveryService sut = new DeliveryService();
        DateTime deliveryDate = DateTime.Now
            .AddDays(daysFromNow); 
        Delivery delivery = new Delivery
        {
            Date = deliveryDate
        };
        bool isValid = sut.IsDeliveryValid(delivery);
        Assert.Equal(expected, isValid); 
    }
}

کد ۳.۱۱

نکته: دقت کن که از ویژگی [Theory] به‌جای [Fact] استفاده شده. یک theory مجموعه‌ای از حقایق درباره‌ی رفتار رو نشون می‌ده.

هر حقیقت حالا با یک خط [InlineData] نمایش داده می‌شه، نه یک تست جداگانه. همچنین نام متد تست به چیزی عمومی‌تر تغییر داده شده؛ دیگه اشاره‌ای به اینکه چه چیزی تاریخ معتبر یا نامعتبر رو تشکیل می‌ده نداره.

با استفاده از تست‌های پارامتری می‌تونی حجم کد تست رو به‌طور قابل‌توجهی کاهش بدی، اما این مزیت هزینه‌ای هم داره: حالا سخت‌تر می‌شه فهمید که متد تست دقیقاً چه حقایقی رو نمایش می‌ده. هرچه تعداد پارامترها بیشتر بشه، این سختی هم بیشتر می‌شه. به‌عنوان یک راه‌حل میانه، می‌تونی تست مثبت رو جدا کنی و از نام‌گذاری توصیفی در جایی که بیشترین اهمیت رو داره بهره ببری—یعنی در تعیین تفاوت بین تاریخ‌های معتبر و نامعتبر برای تحویل، همون‌طور که در لیست بعدی نشون داده شده.

public class DeliveryServiceTests
{
    [InlineData(-1)]
    [InlineData(0)]
    [InlineData(1)]
    [Theory]
    public void Detects_an_invalid_delivery_date(int daysFromNow)
    {
        /* ... */
    }

    [Fact]
    public void The_soonest_delivery_date_is_two_days_from_now()
    {
        /* ... */
    }
}

کد ۳.۱۲

این رویکرد همچنین تست‌های منفی رو ساده‌تر می‌کنه، چون می‌تونی پارامتر boolean مورد انتظار رو از متد تست حذف کنی. و البته می‌تونی متد تست مثبت رو هم به یک تست پارامتری تبدیل کنی تا چندین تاریخ رو بررسی کنه.

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

۳.۵.۱ تولید داده برای تست‌های پارامتری

در استفاده از تست‌های پارامتری (حداقل در ‎.NET‎) نکاتی وجود داره که باید به اون‌ها توجه کنی.
دقت کن که در لیست ۳.۱۱، از پارامتر ‎daysFromNow‎ به‌عنوان ورودی متد تست استفاده کردم.
شاید بپرسی چرا از تاریخ و زمان واقعی استفاده نکردم؟

متأسفانه کد زیر کار نمی‌کنه:

[InlineData(DateTime.Now.AddDays(-1), false)]
[InlineData(DateTime.Now, false)]
[InlineData(DateTime.Now.AddDays(1), false)]
[InlineData(DateTime.Now.AddDays(2), true)]
[Theory]
public void Can_detect_an_invalid_delivery_date(
    DateTime deliveryDate,
    bool expected)
{
    DeliveryService sut = new DeliveryService();
    Delivery delivery = new Delivery
    {
        Date = deliveryDate
    };
    bool isValid = sut.IsDeliveryValid(delivery);
    Assert.Equal(expected, isValid);
}

در ‎C#‎، محتوای همه‌ی ویژگی‌ها (attributes) در زمان کامپایل ارزیابی می‌شه. بنابراین فقط باید از مقادیری استفاده کنی که کامپایلر قادر به درک اون‌هاست، مثل:

  • ثابت‌ها (Constants)
  • مقادیر صریح (Literals)
  • عبارت‌های ‎typeof()

فراخوانی ‎DateTime.Now‎ به زمان اجرای ‎.NET‎ وابسته‌ست و بنابراین مجاز نیست.

راه‌حلی برای غلبه بر این مشکل وجود داره. xUnit یک قابلیت دیگه به نام ‎[MemberData]‎ داره که می‌تونی از اون برای تولید داده‌ی سفارشی و تغذیه‌ی متد تست استفاده کنی. لیست بعدی نشون می‌ده چطور می‌شه تست قبلی رو با استفاده از این قابلیت بازنویسی کرد.

[Theory]
[MemberData(nameof(Data))]
public void Can_detect_an_invalid_delivery_date(
    DateTime deliveryDate,
    bool expected)
{
    DeliveryService sut = new DeliveryService();
    Delivery delivery = new Delivery
    {
        Date = deliveryDate
    };

    bool isValid = sut.IsDeliveryValid(delivery);

    Assert.Equal(expected, isValid);
}

public static List<object[]> Data()
{
    return new List<object[]>
    {
        new object[] { DateTime.Now.AddDays(-1), false },
        new object[] { DateTime.Now, false },
        new object[] { DateTime.Now.AddDays(1), false },
        new object[] { DateTime.Now.AddDays(2), true }
    };
}

کد ۳.۱۳

ویژگی [MemberData] نام یک متد استاتیک رو می‌پذیره که مجموعه‌ای از داده‌های ورودی رو تولید می‌کنه (کامپایلر عبارت nameof(Data) رو به رشته‌ی "Data" تبدیل می‌کنه). هر عنصر از این مجموعه خودش یک کالکشنه که به پارامترهای ورودی متد تست تبدیل می‌شه: deliveryDate و expected. با این قابلیت می‌تونی محدودیت‌های کامپایلر رو دور بزنی و از هر نوع پارامتری در تست‌های پارامتری استفاده کنی.

۳.۶ استفاده از کتابخانه‌ بررسی برای بهبود بیشتر خوانایی تست‌ها

یکی از کارهایی که می‌تونی برای خواناتر کردن تست‌ها انجام بدی، استفاده از کتابخانه‌ی Assertion هست. من شخصاً Fluent Assertions رو ترجیح می‌دم، اما در ‎.NET‎ کتابخانه‌های رقیب دیگه‌ای هم در این زمینه وجود دارن.

مزیت اصلی استفاده از کتابخانه‌ی Assertion اینه که می‌تونی ساختار دستورات بررسی (assertions) رو بازآرایی کنی تا خواناتر بشن. این یکی از تست‌های قبلی ماست:

[Fact]
public void Sum_of_two_numbers()
{
    var sut = new Calculator();
    double result = sut.Sum(10, 20);
    Assert.Equal(30, result);
}

در نمونه‌ی دوم، از کتابخانه‌ی Fluent Assertions استفاده شده:

[Fact]
public void Sum_of_two_numbers()
{
    var sut = new Calculator();
    double result = sut.Sum(10, 20);
    result.Should().Be(30);
}

قسمت Assertion در تست دوم مثل زبان انگلیسی ساده خونده می‌شه، دقیقاً همون‌طور که می‌خوای همه‌ی کدت خونده بشه. ما انسان‌ها ترجیح می‌دیم اطلاعات رو به شکل داستان جذب کنیم. همه‌ی داستان‌ها از این الگوی مشخص پیروی می‌کنن:

Subject action object

برای مثال:
Bob opened the door.

اینجا، Bob یک subject هست، opened یک action و the door یک object. همین قانون در مورد کد هم صدق می‌کنه.
result.Should().Be(30)
خوانایی بهتری نسبت به
Assert.Equal(30, result)
داره، چون از الگوی داستان پیروی می‌کنه. این یک داستان ساده‌ست که در اون result یک subject و should be یک action و ۳۰ یک object هست.

یادداشت: پارادایم برنامه‌نویسی شیءگرا (OOP) تا حدی به خاطر همین مزیت خوانایی موفق شده است. با OOP، شما هم می‌توانید کد را به شکلی ساختاربندی کنید که مانند یک داستان خوانده شود.

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

۳.۷ خلاصه

  • همه‌ی تست‌های واحد باید از الگوی AAA پیروی کنند: Arrange، Act، Assert. اگر یک تست چند بخش arrange یا act یا assert داشته باشد، نشانه‌ی این است که تست در حال بررسی چند واحد رفتار به‌طور هم‌زمان است. اگر هدف تست، واحدی باشد، آن را به چند تست جدا تقسیم کنید—هر تست برای یک عمل.
  • بیش از یک خط در بخش act نشانه‌ی وجود مشکل در API شیء تحت تست (SUT) است. این موضوع باعث می‌شود کلاینت مجبور باشد همیشه این اعمال را با هم انجام دهد که می‌تواند به ناسازگاری‌ها منجر شود. چنین ناسازگاری‌هایی invariant violations نام دارند. عمل محافظت از کد در برابر این ناسازگاری‌ها encapsulation نامیده می‌شود.
  • سیستم تحت تست SUT را در تست‌ها با نام sut مشخص کنید. سه بخش تست را یا با گذاشتن کامنت‌های Arrange، Act و Assert یا با ایجاد خطوط خالی بین این بخش‌ها از هم تفکیک کنید.
  • کدهای اولیه‌ی تست (test fixture initialization) را با معرفی متدهای کارخانه‌ای (factory methods) پیاده سازی کنید، نه با قرار دادن این کدها در سازنده. این کار به حفظ جداسازی بالا بین تست‌ها و همچنین خوانایی بهتر کمک می‌کند.
  • از سیاست نام‌گذاری سخت‌گیرانه برای تست‌ها استفاده نکنید. هر تست را طوری نام‌گذاری کنید که انگار سناریوی آن را برای یک فرد غیر برنامه‌نویس که با دامنه‌ی مسئله آشناست توضیح می‌دهید. کلمات را با underscore جدا کنید و نام متد تحت تست را در نام تست قرار ندهید.
  • تست‌های پارامتری به کاهش میزان کد مورد نیاز برای تست‌های مشابه کمک می‌کنند. ایراد این روش این است که با عمومی‌تر شدن تست‌ها، نام آن‌ها کمتر خوانا می‌شود.
  • کتابخانه‌های assertion به شما کمک می‌کنند خوانایی تست‌ها را بیشتر کنید، چون ترتیب کلمات در assertionها را اصلاح می‌کنند تا مثل زبان انگلیسی ساده خوانده شوند.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *