توسعه آزمونمحور (TDD)
در TDD اول یک تست کوچک مینویسیم که شکست میخورد. بعد سادهترین کدی را مینویسیم که آن را سبز کند. بعد کد را تمیز میکنیم. این چرخه کوتاه هم تست میسازد و هم طراحی کد را بهتر میکند.
نویسنده: bezzad
مشکل: تستی که هیچ وقت نوشته نمیشود
تیم فروش فروشگاه ما یک قانون جدید میخواهد: «هزینه ارسال ۵۰ هزار تومان است. ولی سفارش ۵۰۰ هزار تومانی یا بیشتر، ارسال رایگان دارد.»
روش معمول این است:
- کد کامل را یکجا مینویسیم. گاهی همراه چند حالت اضافه که «شاید بعداً لازم شود».
- دستی امتحان میکنیم. کار میکند. خوشحالیم.
- میگوییم تست را بعداً مینویسیم. ولی کار بعدی میرسد و تست هیچ وقت نوشته نمیشود.
- اگر هم بنویسیم، سخت است. چون کلاس از اول برای تست شدن طراحی نشده است. مثلاً مستقیم به دیتابیس یا ساعت سیستم وصل است.
ایده TDD: اول تست، بعد کد
در توسعه آزمونمحور (Test-Driven Development) ترتیب را برعکس میکنیم. هیچ خط کد اصلی نمینویسیم، مگر برای سبز کردن یک تست قرمز. کار در یک چرخه کوتاه و تکراری انجام میشود:
- قرمز (Red). یک تست کوچک برای یک رفتار جدید بنویس. آن را اجرا کن و ببین که شکست میخورد. اگر سبز شد، یا رفتار از قبل وجود داشت یا تست اشتباه است.
- سبز (Green). سادهترین کدی را بنویس که تست را سبز کند. در این قدم زیبایی کد مهم نیست. فقط سبز شدن مهم است.
- بازسازی (Refactor). حالا که همه تستها سبزند، کد را تمیز کن. اسمها را بهتر کن و تکرار را حذف کن. بعد از هر تغییر کوچک، تستها را اجرا کن.
مثال زنده
قانون هزینه ارسال را قدم به قدم با TDD بساز. به رنگ هر قدم و به تغییر کد دقت کن:
به قدم دوم دقت کن. کد «همیشه ۵۰ هزار برگردان» عجیب به نظر میرسد. ولی این کار عمدی است و دو دلیل دارد:
- قدم کوچک میماند. اگر چیزی خراب شد، میدانیم دقیقاً کدام تغییر آن را خراب کرد.
- تست بعدی کد را مجبور به کلی شدن میکند. کنت بک (Kent Beck) به این کار Triangulation میگوید. یعنی با دو یا چند مثال، کد را به سمت قانون کلی هل میدهیم.
اگر راه حل از اول کاملاً روشن است، لازم نیست این قدمهای خیلی کوچک را برداری. میتوانی مستقیم کد درست را بنویسی. ولی وقتی مطمئن نیستی، قدمها را کوچکتر کن.
کد نهایی
بعد از چند دور، تستها و کد این شکل را دارند:
public class ShippingCostTests
{
[Theory]
[InlineData(200_000, false, 50_000)]
[InlineData(499_999, false, 50_000)]
[InlineData(500_000, false, 0)]
[InlineData(200_000, true, 0)]
public void Shipping_cost_follows_the_rules(int total, bool isVip, int expected)
{
var cost = ShippingCost.For(total, isVip);
Assert.Equal(expected, cost);
}
}
public static class ShippingCost
{
private const decimal FreeFrom = 500_000m;
private const decimal Standard = 50_000m;
public static decimal For(decimal orderTotal, bool isVip) =>
isVip || orderTotal >= FreeFrom ? 0m : Standard;
}
به دو ردیف ۴۹۹٬۹۹۹ و ۵۰۰٬۰۰۰ دقت کن. اینها مرز قانون هستند. نوشتن تست قبل از کد، ما را مجبور میکند درباره همین مرزها فکر کنیم و از تیم فروش بپرسیم: «۵۰۰ هزار دقیقاً رایگان است یا از آن به بعد؟»
چرا TDD کمک میکند؟
- طراحی بهتر. وقتی اول تست را مینویسی، اول از دید استفادهکننده به کلاس نگاه میکنی. کلاسی که تستش سخت است، معمولاً طراحی بدی دارد. این را خیلی زود میفهمی.
- فقط کد لازم. هر خط کد به خاطر یک تست نوشته شده است. کد اضافه «برای روز مبادا» کمتر نوشته میشود.
- شبکه ایمنی. هر رفتار یک تست دارد. پس بازسازی بعدی ترسناک نیست.
- مستندات زنده. اسم تستها قانونهای کسبوکار را توضیح میدهند و همیشه با کد هماهنگ هستند.
- قدمهای کوچک. همیشه چند دقیقه با یک حالت سالم فاصله داری. اگر گم شدی، تغییر آخر را برمیگردانی.
دو سبک: از داخل به بیرون، از بیرون به داخل
از داخل به بیرون (سبک کلاسیک)
- از کوچکترین قانون شروع میکنی، مثل محاسبه هزینه ارسال.
- کمکم کلاسهای بزرگتر را روی آن میسازی.
- معمولاً Mock کمتری لازم است.
- خطر: شاید چیزی بسازی که لایه بالایی لازم ندارد.
از بیرون به داخل (سبک لندن)
- از یک تست برای کل قابلیت، مثلاً Endpoint ثبت سفارش، شروع میکنی.
- کلاسهای داخلی را اول با Mock فرض میکنی، بعد یکی یکی میسازی.
- رابط کلاسها از نیاز واقعی استفادهکننده بیرون میآید.
- خطر: Mock زیاد، تست را به پیادهسازی وابسته میکند.
قانونهای مهم
- هر بار فقط یک تست قرمز. چند تست قرمز با هم یعنی قدم خیلی بزرگ است.
- قدم بازسازی را جا نینداز. بدون آن، TDD فقط کد شلوغ با تست تولید میکند.
- در قدم سبز، کد اضافه ننویس. اگر رفتار جدیدی به ذهنت رسید، آن را در یک لیست بنویس. بعداً با یک تست جدید سراغش برو.
- رفتار را تست کن، نه پیادهسازی را. وگرنه قدم بازسازی تستها را میشکند و TDD دردناک میشود.
- تستها سریع باشند. چرخه TDD چند دقیقه است. اگر اجرای تستها یک دقیقه طول بکشد، چرخه از بین میرود.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| نوشتن ده تست، بعد نوشتن کد | چند تست قرمز با هم. معلوم نیست از کجا شروع کنی. | یک تست، یک قدم. |
| ندیدن قرمز قبل از سبز | تستی که هیچ چیزی را چک نمیکند، سبز میماند. | همیشه اول شکست را ببین. |
| حذف قدم بازسازی | کد کار میکند، ولی کثیف و پر از تکرار است. | بعد از هر سبز، کمی تمیز کن. |
| تست متدهای خصوصی و جزئیات | هر بازسازی تستها را میشکند. | فقط از راه رفتار عمومی تست کن. |
| اجبار به TDD برای همه چیز | زمان روی کدهای آزمایشی و دورریختنی هدر میرود. | جایی که منطق و قانون هست. |
چه وقت TDD؟
مناسب
- قانونهای کسبوکار با شرط و حالت زیاد.
- رفع باگ: اول تستی که باگ را نشان میدهد، بعد اصلاح.
- محاسبهها، تبدیلها و الگوریتمها.
- وقتی ورودی و خروجی مورد انتظار روشن است.
نامناسب
- کد آزمایشی (Spike) برای یاد گرفتن یک کتابخانه. بعد از یادگیری، آن را دور بریز و با TDD دوباره بنویس.
- چیدمان و ظاهر صفحه.
- وقتی هنوز نمیدانی مسئله دقیقاً چیست.
خلاصه در شش خط
- در TDD هیچ کد اصلی بدون یک تست قرمز نوشته نمیشود.
- چرخه سه قدم دارد: قرمز، سبز، بازسازی.
- در قدم سبز سادهترین کد کافی است. تستهای بعدی کد را کلیتر میکنند.
- بازسازی فقط وقتی انجام میشود که همه تستها سبزند.
- سود اصلی TDD طراحی بهتر و شبکه ایمنی برای تغییر است، نه فقط تعداد تست.
- برای منطق و قانون عالی است. برای کد آزمایشی و ظاهر صفحه لازم نیست.