TDD چیه و چرا نوشتن تست قبل از کد، منطقیتر از چیزیه که به نظر میرسه
حدود ۱۱ دقیقه مطالعه
اولین باری که اسم Test-Driven Development رو میشنوی، احتمالاً به نظرت برعکس و غیرمنطقی میاد: چطور میشه تستی برای کدی نوشت که هنوز وجود نداره؟ ولی همین ترتیب برعکس، دقیقاً چیزیه که TDD رو از «نوشتن تست بعد از تموم شدن کد» متفاوت میکنه و یه سری مزیت واقعی به همراه داره که فقط با تجربه کردنش قابل درکه.
چرخهی Red-Green-Refactor
TDD حول یه چرخهی سهمرحلهای میچرخه که مدام تکرار میشه:
Red: اول یه تست مینویسی برای رفتاری که هنوز پیادهسازی نشده. طبیعتاً این تست fail میشه (قرمز)، چون کدی که قراره تستش کنه اصلاً وجود نداره.
Green: حالا کمترین و سادهترین کد ممکن رو مینویسی تا فقط همون تست پاس بشه. نه بیشتر، نه optimizedتر؛ فقط کافیه تست سبز بشه.
Refactor: با اطمینان از اینکه تست پاس میشه، حالا کد رو تمیز میکنی، duplicate ها رو حذف میکنی، اسمگذاری رو بهتر میکنی؛ بدون نگرانی از اینکه چیزی رو خراب کنی، چون تست بهت خبر میده اگه رفتار عوض بشه.
# مرحلهی Red: تستی برای تابعی که هنوز وجود نداره
def test_calculate_discount():
assert calculate_discount(100, "vip") == 85
# اجرا میکنیم: fail میشه چون calculate_discount تعریف نشده
# مرحلهی Green: کمترین کد برای پاس شدن تست
def calculate_discount(price, customer_type):
if customer_type == "vip":
return price * 0.85
return price
# مرحلهی Refactor: حالا که تست داریم، میتونیم منطق رو تمیزتر کنیم
DISCOUNT_RATES = {"vip": 0.85, "regular": 0.95}
def calculate_discount(price, customer_type):
rate = DISCOUNT_RATES.get(customer_type, 1.0)
return price * rate
بعد از این سه مرحله، دوباره از اول شروع میکنی: یه تست جدید برای رفتار بعدی مینویسی و همین چرخه رو تکرار میکنی.
چرا این ترتیب مهمه
نکتهای که خیلی وقتها نادیده گرفته میشه اینه که فایدهی اصلی TDD صرفاً «داشتن تست» نیست؛ چیزی که ارزش داره، تأثیریه که نوشتن تست قبل از کد روی طراحی میذاره. وقتی مجبوری قبل از نوشتن پیادهسازی، مشخص کنی که تابعت چه ورودیهایی میگیره و چه خروجیای برمیگردونه، بهصورت طبیعی به سمت کدی میری که interface ساده و قابل تست داره. کدی که تستش سخته (مثلاً به خاطر وابستگیهای پنهون یا side effect های زیاد)، معمولاً همون کدیه که طراحیش هم ایراد داره.
این برعکس روشیه که خیلی از برنامهنویسها عادت دارن: اول کد رو مینویسن، بعد سعی میکنن براش تست بنویسن. تو اون حالت، تستها معمولاً فقط تأیید میکنن که کد چیکار میکنه (نه لزوماً چیزی که باید بکنه)، و چون کد از قبل نوشته شده، خیلی وقتها طراحیش برای تست کردن مناسب نیست؛ مثلاً پر از متدهای استاتیک یا وابستگی مستقیم به دیتابیس.
یه مثال واقعیتر: سرویس ثبتنام کاربر
بیایم یه ویژگی رو با TDD پیش ببریم که واقعاً تو یه پروژهی واقعی هم پیش میاد: سرویسی که یه کاربر جدید رو ثبتنام میکنه. این مثال عمداً یه dependency واقعی (دیتابیس) داره تا نشون بده TDD چطور طراحی رو به سمت قابلتستبودن هول میده.
اول، قبل از نوشتن هیچ کدی، مشخص میکنیم سرویس به چه چیزی نیاز داره: یه repository برای چک کردن و ذخیرهی کاربر. این dependency رو از همون اول بهصورت interface تزریق میکنیم، نه اینکه مستقیم داخل سرویس یه دیتابیس واقعی رو صدا بزنیم:
# قدم اول (Red): تستی برای رد کردن ایمیل تکراری
class FakeUserRepository:
def __init__(self, existing_emails=None):
self.existing_emails = existing_emails or set()
self.saved_users = []
def email_exists(self, email):
return email in self.existing_emails
def save(self, user):
self.saved_users.append(user)
def test_rejects_duplicate_email():
repo = FakeUserRepository(existing_emails={"ali@example.com"})
service = UserRegistrationService(repo)
result = service.register("ali@example.com", "password123")
assert result.success is False
assert result.error == "EMAIL_ALREADY_EXISTS"
assert len(repo.saved_users) == 0
چون UserRegistrationService هنوز وجود نداره، این تست fail میشه. حالا کمترین کد ممکن رو مینویسیم تا سبز بشه:
# قدم دوم (Green)
class RegistrationResult:
def __init__(self, success, error=None):
self.success = success
self.error = error
class UserRegistrationService:
def __init__(self, repository):
self.repository = repository
def register(self, email, password):
if self.repository.email_exists(email):
return RegistrationResult(success=False, error="EMAIL_ALREADY_EXISTS")
self.repository.save({"email": email, "password": password})
return RegistrationResult(success=True)
حالا یه تست جدید برای قانون بعدی اضافه میکنیم؛ رد کردن پسورد ضعیف:
def test_rejects_weak_password():
repo = FakeUserRepository()
service = UserRegistrationService(repo)
result = service.register("new@example.com", "123")
assert result.success is False
assert result.error == "PASSWORD_TOO_WEAK"
assert len(repo.saved_users) == 0
def test_successful_registration_saves_user():
repo = FakeUserRepository()
service = UserRegistrationService(repo)
result = service.register("new@example.com", "password123")
assert result.success is True
assert repo.saved_users[0]["email"] == "new@example.com"
با اضافه شدن این تستها، سرویس رو گسترش میدیم:
# قدم سوم (Green + Refactor)
class UserRegistrationService:
MIN_PASSWORD_LENGTH = 8
def __init__(self, repository):
self.repository = repository
def register(self, email, password):
if self.repository.email_exists(email):
return RegistrationResult(success=False, error="EMAIL_ALREADY_EXISTS")
if len(password) < self.MIN_PASSWORD_LENGTH:
return RegistrationResult(success=False, error="PASSWORD_TOO_WEAK")
self.repository.save({"email": email, "password": password})
return RegistrationResult(success=True)
نکتهی مهم اینجا FakeUserRepository است. چون از همون اول تصمیم گرفتیم دیتابیس رو بهصورت یه dependency قابل تزریق طراحی کنیم (نه اینکه مستقیم داخل سرویس به یه دیتابیس واقعی وصل بشیم)، تونستیم منطق ثبتنام رو کاملاً بدون نیاز به دیتابیس واقعی، سریع و ایزوله تست کنیم. این دقیقاً همون تأثیریه که TDD روی طراحی میذاره: مجبورت میکنه از اول به شکل قابلتست فکر کنی.
مزیتهایی که معمولاً کمتر بهشون اشاره میشه
فراتر از اینکه «باگ کمتر میشه»، TDD یه فایدهی مهم دیگه هم داره: تستها بهعنوان مستندات زنده عمل میکنن. وقتی یه توسعهدهندهی جدید به تیم میپیوندد و میخواد بفهمه یه تابع دقیقاً چه رفتاری داره، خوندن تستهاش خیلی وقتها روشنتر از خوندن کد اصلیه، چون تستها دقیقاً حالتهای مرزی و انتظارات رو مشخص میکنن.
فایدهی دیگه، اعتماد به refactor کردنه. تو کدبیسی که پوشش تست خوبی داره، تغییر دادن یه بخش از کد ترسناک نیست، چون اگه چیزی رو بشکنی، سریع خبردار میشی. بدون این پوشش، خیلی از تیمها از ترس خراب کردن چیزی، از refactor کردن کد قدیمی اجتناب میکنن، حتی وقتی میدونن که کد نیاز به تمیز شدن داره.
TDD موقع ساختن API
وقتی داری یه REST API میسازی، معمولاً با دو لایهی متفاوت طرفی: منطق کسبوکار (business logic) و لایهی HTTP که این منطق رو در قالب endpoint در معرض دید میذاره. اشتباه رایج اینه که TDD رو مستقیم روی endpoint اجرا کنیم؛ یعنی تست بنویسیم که یه request واقعی به /api/users میفرسته و response رو چک میکنه. این کار میشه، ولی اینجور تستها کند هستن (چون سرور واقعی بالا میاد) و وقتی fail میشن، سخته بفهمی مشکل تو لایهی HTTP بوده یا تو منطق کسبوکار.
روش بهتر، جدا کردن این دو لایه و تست کردن هر کدوم جداگانهست. لایهی business logic (مثل همون UserRegistrationService بالا) رو کاملاً بدون درگیر شدن با HTTP، با unit test پیش میبریم. بعد یه لایهی نازک controller/route داریم که فقط مسئول parse کردن request و صدا زدن سرویسه:
# تست لایهی HTTP، جدا از منطق کسبوکار
def test_register_endpoint_returns_201_on_success(test_client, mock_service):
mock_service.register.return_value = RegistrationResult(success=True)
response = test_client.post("/api/users", json={
"email": "new@example.com",
"password": "password123"
})
assert response.status_code == 201
mock_service.register.assert_called_once_with("new@example.com", "password123")
def test_register_endpoint_returns_409_on_duplicate_email(test_client, mock_service):
mock_service.register.return_value = RegistrationResult(
success=False, error="EMAIL_ALREADY_EXISTS"
)
response = test_client.post("/api/users", json={
"email": "existing@example.com",
"password": "password123"
})
assert response.status_code == 409
اینجا mock_service جای سرویس واقعی نشسته، پس این تستها فقط چک میکنن که controller درست request رو به سرویس پاس میده و status code مناسب رو برمیگردونه؛ نه اینکه منطق ثبتنام درست کار میکنه یا نه (که قبلاً با unit test پوشش دادیمش). این جداسازی باعث میشه هم تستها سریع اجرا بشن، هم وقتی چیزی fail میشه دقیقاً بدونی مشکل کجاست.
برای هر feature تست بنویسیم یا برای کل response؟
این سؤالی است که خیلی وقتها موقع تستنویسی API پیش میاد، و جواب درست «هر دو، ولی تو دو سطح متفاوت» است، نه انتخاب یکی بهجای اون یکی.
بخش اصلی تستها باید سطح feature/قانون کسبوکار باشن؛ یعنی همون چیزی که تو مثال UserRegistrationService دیدیم: یه تست برای ایمیل تکراری، یه تست برای پسورد ضعیف، یه تست برای ثبتنام موفق. این تستها سریع هستن، نیازی به بالا اومدن سرور واقعی ندارن، و وقتی fail میشن دقیقاً مشخصه کدوم قانون شکسته. اگه فقط به تست کردن کل response اکتفا کنی، وقتی یه تست fail بشه فقط میفهمی «یه جای ثبتنام خراب شده»، نه اینکه دقیقاً کدوم قانون.
در کنارش، به تعداد کمتر، به تستهایی هم نیاز داری که واقعاً endpoint رو صدا بزنن و کل response رو چک کنن؛ status code، شکل JSON خروجی، هدرها. این تستها چیزهایی رو میگیرن که unit test نمیتونه: مسیر اشتباه تو routing، خطای serialize شدن، middleware ای که درست کار نمیکنه، یا auth ای که درست وصل نشده. این تستها تضمین میکنن که تکهها واقعاً با هم درست کار میکنن، نه فقط هر کدوم جدا.
یه ترکیب عملی که خوب جواب میده اینه: به ازای هر قانون کسبوکار یا حالت مرزی، یه unit test (که تعدادشون زیاده و ارزون تمومن)، و به ازای هر route فقط چند تا تست سطح endpoint (یه مسیر موفق، و یکی دو تا از رایجترین حالتهای خطا) که فقط تأیید میکنن سیمکشی بین لایهها درسته. این دقیقاً همون ایدهی test pyramid است که چند پاراگراف پایینتر بهش میرسیم؛ یه عالمه تست باریک و سریع تو پایین، و یه لایهی نازکتر از تستهای گستردهتر روش.
علاوه بر این، برای API هایی که قرارداد مشخصی با کلاینت دارن (مثلاً یه فرانتاند جدا یا یه سرویس دیگه که ازش استفاده میکنه)، میتونی از contract test هم استفاده کنی؛ تستی که تضمین میکنه response API همیشه با یه schema مشخص (مثلاً OpenAPI) مطابقت داره، حتی اگه پیادهسازی داخلیش عوض بشه.
TDD موقع ساختن سرویسها و میکروسرویسها
تو معماری میکروسرویس، چالش اصلی TDD اینه که هر سرویس معمولاً به سرویسهای دیگه یا زیرساختهای خارجی (message queue، دیتابیس، یه API خارجی) وابستهست. اگه بخوای برای هر تست، این وابستگیهای واقعی رو بالا بیاری، تستهات هم کند میشن و هم شکننده (چون یه قطعی موقت تو شبکه میتونه تست رو fail کنه، نه لزوماً باگ تو کدت).
راهحل رایج، جدا کردن تستها به چند سطح:
Unit test با fake/mock: درست مثل مثال بالا، منطق داخلی سرویس رو با نسخههای fake از وابستگیهای بیرونی تست میکنیم. این تستها سریعترین و پرتکرارترین بخش هستن.
Integration test محدود: یه لایهی بالاتر، جایی که سرویس واقعاً به یه نسخهی تست از دیتابیس یا message broker وصل میشه (مثلاً با Docker) تا مطمئن بشیم کدی که با اون زیرساخت حرف میزنه (مثل query های SQL یا serialize کردن پیام) درست کار میکنه. این تستها کندترن، پس تعدادشون معمولاً خیلی کمتر از unit test هاست.
Contract test بین سرویسها: وقتی سرویس A از سرویس B استفاده میکنه، بهجای اینکه تو تستهای سرویس A واقعاً سرویس B رو صدا بزنی، یه قرارداد مشخص (مثلاً با Pact) تعریف میکنی که هر دو طرف بهش پایبند میمونن. این باعث میشه بتونی سرویسها رو مستقل از هم توسعه و deploy کنی، بدون اینکه برای هر تغییر کوچیک مجبور باشی کل زنجیره رو بالا بیاری.
این هرم (تعداد زیاد unit test، تعداد متوسط integration test، و تعداد کم contract/end-to-end test) دقیقاً همون چیزیه که بهعنوان test pyramid شناخته میشه، و TDD معمولاً بیشترین کاربردش رو تو پایینترین لایهی این هرم، یعنی unit test ها داره.
کجا TDD بهسختی جواب میده
TDD همیشه ابزار مناسبی نیست. برای کدی که ماهیتش اکتشافیه (مثلاً وقتی داری با یه API خارجی آشنا میشی یا یه پروتوتایپ سریع میسازی تا ببینی یه ایده اصلاً شدنیه یا نه)، نوشتن تست قبل از اینکه بدونی حتی چه شکلی قراره باشه راهحل نهایی، میتونه بیشتر وقتگیر باشه تا مفید. همینطور برای UI هایی که رفتارشون بیشتر بصریه تا منطقی، نوشتن تستهای یونیت خیلی وقتها کمک چندانی نمیکنه و باید سراغ ابزارهای دیگهای مثل تستهای end-to-end یا visual regression رفت.
یه اشتباه رایج دیگه اینه که تیمها سعی میکنن TDD رو صددرصد و برای هر خط کد اجرا کنن، در حالی که هدف اصلی این نیست که هر تابعی حتماً از قبل تست داشته باشه، بلکه اینه که فرآیند فکر کردن قبل از نوشتن کد، طراحی بهتری بسازه. جایی که این فرآیند فایده نداره (مثل کد اکتشافی یا اسکریپت یکبار مصرف)، اصراف روی TDD فقط باعث کندی بدون سودِ متناسب میشه.
