یک توسعه‌دهنده باتجربه را تصور کنید که شش ماه پیش کدی نوشته. حالا می‌خواهد آن را بهتر کند. ساختارش را تمیزتر کند، یک تابع طولانی را به چند تابع کوچک‌تر تبدیل کند، یک متغیر با نام مبهم را تغییر دهد. اما یک سوال آرام در ذهنش هست: «اگر این تغییر چیزی را خراب کند چطور؟» این سوال، بدون تست، جواب مشخصی ندارد.

ریفکتورینگ چیست؟

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

چرا تست‌ها ریفکتورینگ را امن می‌کنند؟

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

در این درس چه چیزی یاد میگیرید؟

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

ریفکتورینگ چیست و چه زمانی به آن نیاز داریم؟

کد نوشته می‌شود، کار می‌کند، و بعد کنار گذاشته می‌شود. این سرنوشت خیلی از کدهاست. اما در پروژه‌های واقعی، کد به ندرت کنار گذاشته می‌شود؛ ماه‌ها و گاهی سال‌ها خوانده، تغییر داده و گسترش پیدا می‌کند.

همین جاست که ریفکتورینگ اهمیت پیدا می‌کند.

ریفکتورینگ چیست؟

ریفکتورینگ یک تکنیک منضبط برای بازسازی کد موجود است؛ به گونه‌ای که ساختار داخلی آن بهبود پیدا کند، بدون اینکه رفتار بیرونی‌اش تغییر کند.

دو کلمه در این تعریف اهمیت ویژه دارند: «ساختار داخلی» و «رفتار بیرونی».

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

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

ریفکتورینگ با بازنویسی فرق دارد

این دو را نباید با هم اشتباه گرفت.

بازنویسی یعنی کد قدیمی را دور می‌اندازید و از صفر می‌نویسید. ریفکتورینگ یعنی همان کد را، قدم به قدم، بهتر می‌کنید. بسیاری از توسعه‌دهندگان وقتی می‌گویند «ریفکتور»، منظورشان «بازنویسی» است. اما Martin Fowler تعریف دقیقی دارد: ریفکتورینگ تغییری است در ساختار داخلی کد که رفتار قابل مشاهده آن را تغییر نمی‌دهد.

در پروژه کیف پول دیجیتال، اگر متد `withdraw` را به چند متد کوچک‌تر تقسیم کنید ولی هنوز همان محدودیت‌های برداشت را داشته باشد، این ریفکتورینگ است. اگر کل کلاس را از صفر بنویسید، این بازنویسی است.

بوی بد کد (Code Smell)

Code Smell یک نشانه سطحی است که معمولاً به یک مشکل عمیق‌تر در سیستم اشاره دارد. این اصطلاح اولین بار توسط Kent Beck ابداع شد.

بوی بد کد، کد را از کار نمی‌اندازد. برنامه همچنان اجرا می‌شود. اما این نشانه‌ها می‌گویند که اگر چیزی تغییر نکند، نگهداری و گسترش کد در آینده سخت‌تر می‌شود.

Code Smell معمولاً باگ نیست؛ از نظر فنی اشتباه نیست و مانع اجرای برنامه نمی‌شود. اما ضعف‌هایی در طراحی نشان می‌دهد که ممکن است توسعه را کند کند یا خطر باگ و شکست در آینده را افزایش دهد.

رایج‌ترین نشانه‌هایی که می‌گویند کد به ریفکتور نیاز دارد

تابع طولانی: یک لیست طولانی از پارامترها خواندن را سخت می‌کند و فراخوانی و تست تابع را پیچیده می‌کند. ممکن است نشان دهد که هدف تابع درست تعریف نشده. وقتی یک تابع بیش از ۲۰ خط دارد، معمولاً می‌توان آن را به توابع کوچک‌تر با مسئولیت مشخص تقسیم کرد.

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

نام‌های مبهم: متغیری به نام x یا تابعی به نام do_stuff. خواندن این کد بعد از یک ماه، مثل خواندن یک متن بدون فاصله است.

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

چه زمانی نباید ریفکتور کرد؟

Martin Fowler پیشنهاد می‌کند وقت خود را روی کد زشتی که نیازی به تغییر ندارد هدر ندهید، یا وقتی بازنویسی آسان‌تر از ریفکتور است.

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

نقش pytest در این فرآیند

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

بدون تست، هر ریفکتور یک قمار است. با تست، یک فرآیند کنترل‌شده است. تست‌هایی که در این دوره نوشتید، دقیقاً همین نقش را دارند: هر بار که کدی تغییر می‌کند، در کمتر از چند ثانیه می‌گویند آیا رفتار برنامه دست‌نخورده مانده یا نه.

در بخش‌های بعدی، این فرآیند را روی پروژه کیف پول دیجیتال به صورت عملی می‌بینید.

تست‌ها چطور ریفکتورینگ را امن می‌کنند؟

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

تست‌های خودکار این معادله را تغییر می‌دهند.

مجموعه تست به عنوان شبکه ایمنی

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

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

چرخه عملی: ریفکتور با pytest

فرآیند ریفکتور ایمن با pytest یک ریتم مشخص دارد:

  • اول: مطمئن شوید همه تست‌ها سبز هستند. هیچ‌وقت ریفکتور را روی کدی که تستش قرمز است شروع نکنید؛ چون نمی‌دانید مشکل از قبل بوده یا ریفکتور ایجاد کرده.
  • دوم: یک تغییر کوچک در کد انجام دهید. کوچک، نه بزرگ.
  • سوم: بلافاصله تست‌ها را اجرا کنید.
  • چهارم: اگر سبز ماندند، تغییر بعدی. اگر قرمز شدند، یک قدم برگردید.

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

یک مثال واقعی از پروژه کیف پول دیجیتال

فرض کنید متد withdraw در کلاس DigitalWallet به این شکل نوشته شده:

def withdraw(self, amount: float):
    if amount <= 0:
        raise ValueError("مبلغ باید بیشتر از صفر باشد.")
    if amount > self.balance:
        raise ValueError("موجودی کافی نیست.")
    self.balance -= amount

این کد کار می‌کند. اما اگر بخواهیم متد transfer هم بنویسیم، همان دو شرط اعتبارسنجی باید دوباره نوشته شوند. این تکرار یک Code Smell است.

ریفکتور پیشنهادی: استخراج اعتبارسنجی به یک متد مستقل:

def _validate_amount(self, amount: float):
    if amount <= 0:
        raise ValueError("مبلغ باید بیشتر از صفر باشد.")

def _check_sufficient_balance(self, amount: float):
    if amount > self.balance:
        raise ValueError("موجودی کافی نیست.")

def withdraw(self, amount: float):
    self._validate_amount(amount)
    self._check_sufficient_balance(amount)
    self.balance -= amount

رفتار withdraw تغییر نکرده. فقط ساختار داخلی‌اش تمیزتر شده. حالا تست‌ها را اجرا کنید:

pytest tests/test_wallet.py

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

تست‌ها چه رفتاری را تضمین می‌کنند؟

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

این یعنی:

  • واریز ۵۰ تومان، همچنان موجودی را ۵۰ تومان افزایش می‌دهد 
    برداشت بیش از موجودی، همچنان ValueError می‌دهد 
    برداشت مبلغ منفی، همچنان خطا می‌دهد 

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

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

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

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

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

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

یک نکته مهم درباره سرعت اجرای تست

مجموعه تست باید در کمتر از ۱۰ ثانیه اجرا شود تا ریفکتور با چرخه سریع عملی بماند.

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

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

ریفکتور اول: تابع طولانی را تقسیم کنید

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

این جمله را Martin Fowler در کتاب Refactoring نوشته. کتابی که از سال ۱۹۹۹ هنوز مرجع اصلی ریفکتورینگ در دنیاست.

پروژه کیف پول دیجیتال هم از این قانون مستثنی نیست.

کدی که به ریفکتور نیاز دارد

فرض کنید متد transfer را به پروژه اضافه کرده‌اید. این متد موجودی را از یک کیف پول به کیف پول دیگر منتقل می‌کند:

def transfer(self, amount: float, target_wallet):
    if amount <= 0:
        raise ValueError("مبلغ انتقال باید بیشتر از صفر باشد.")
    if amount > self.balance:
        raise ValueError("موجودی کافی برای انتقال نیست.")
    if target_wallet is None:
        raise ValueError("کیف پول مقصد معتبر نیست.")
    transaction_fee = amount * 0.01
    total_deduction = amount + transaction_fee
    if total_deduction > self.balance:
        raise ValueError("موجودی کافی برای پرداخت کارمزد نیست.")
    self.balance -= total_deduction
    target_wallet.balance += amount
    self.transaction_count += 1
    target_wallet.transaction_count += 1

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

تکنیک Extract Method

تکنیک Extract Method شامل جداسازی بخشی از کد که یک وظیفه مشخص انجام می‌دهد به یک متد مستقل است.

قبل از هر چیز، تست‌ها را اجرا کنید تا مطمئن شوید همه سبز هستند:

pytest tests/test_wallet.py -v

حالا ریفکتور را شروع می‌کنیم. یک تغییر کوچک، نه همه چیز با هم.

مرحله اول: استخراج اعتبارسنجی مبلغ

def _validate_transfer_amount(self, amount: float):
    if amount <= 0:
        raise ValueError("مبلغ انتقال باید بیشتر از صفر باشد.")

def transfer(self, amount: float, target_wallet):
    self._validate_transfer_amount(amount)
    if amount > self.balance:
        raise ValueError("موجودی کافی برای انتقال نیست.")
    if target_wallet is None:
        raise ValueError("کیف پول مقصد معتبر نیست.")
    transaction_fee = amount * 0.01
    total_deduction = amount + transaction_fee
    if total_deduction > self.balance:
        raise ValueError("موجودی کافی برای پرداخت کارمزد نیست.")
    self.balance -= total_deduction
    target_wallet.balance += amount
    self.transaction_count += 1
    target_wallet.transaction_count += 1

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

مرحله دوم: استخراج محاسبه کارمزد

def _calculate_fee(self, amount: float) -> float:
    return amount * 0.01

def transfer(self, amount: float, target_wallet):
    self._validate_transfer_amount(amount)
    if amount > self.balance:
        raise ValueError("موجودی کافی برای انتقال نیست.")
    if target_wallet is None:
        raise ValueError("کیف پول مقصد معتبر نیست.")
    fee = self._calculate_fee(amount)
    total_deduction = amount + fee
    if total_deduction > self.balance:
        raise ValueError("موجودی کافی برای پرداخت کارمزد نیست.")
    self.balance -= total_deduction
    target_wallet.balance += amount
    self.transaction_count += 1
    target_wallet.transaction_count += 1

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

مرحله سوم: استخراج منطق به‌روزرسانی موجودی

def _execute_transfer(self, total_deduction: float, amount: float, target_wallet):
    self.balance -= total_deduction
    target_wallet.balance += amount
    self.transaction_count += 1
    target_wallet.transaction_count += 1

def transfer(self, amount: float, target_wallet):
    self._validate_transfer_amount(amount)
    if amount > self.balance:
        raise ValueError("موجودی کافی برای انتقال نیست.")
    if target_wallet is None:
        raise ValueError("کیف پول مقصد معتبر نیست.")
    fee = self._calculate_fee(amount)
    total_deduction = amount + fee
    if total_deduction > self.balance:
        raise ValueError("موجودی کافی برای پرداخت کارمزد نیست.")
    self._execute_transfer(total_deduction, amount, target_wallet)

نتیجه نهایی

متد transfer حالا به این شکل است:

def transfer(self, amount: float, target_wallet):
    self._validate_transfer_amount(amount)
    self._validate_target_wallet(target_wallet)
    fee = self._calculate_fee(amount)
    total_deduction = amount + fee
    self._validate_sufficient_balance(total_deduction)
    self._execute_transfer(total_deduction, amount, target_wallet)

شش خط. هر خط یک وظیفه مشخص. خواندن این متد مثل خواندن یک دستورالعمل ساده است.

وقتی رفتار توسط تست‌ها پوشش داده شده، می‌توانید کد را با اطمینان بیشتری سازماندهی مجدد کنید. تست‌ها تأیید می‌کنند که رفتار کد تغییر نکرده است.

چرا این ریفکتور مهم است؟

متد استخراج‌شده می‌تواند در جاهای دیگر هم استفاده شود و تکرار کد را حذف می‌کند. متد _validate_transfer_amount و _calculate_fee حالا اگر متد withdraw هم نیاز به همین منطق داشت، می‌تواند از آن‌ها استفاده کند. بدون نوشتن دوباره.

یک آزمون ساده برای اینکه بدانید آیا باید Extract Method انجام دهید: اگر برای توضیح دادن یک بخش از کد نیاز به یک کامنت دارید، آن بخش احتمالاً باید یک متد مستقل با نام توصیفی باشد.

ریفکتور دوم: حذف کد تکراری

در بخش قبل، متد transfer را به چند متد کوچک تقسیم کردیم. حالا یک مشکل جدید پیدا شده. اگر به کلاس DigitalWallet نگاه کنید، متدهای deposit، withdraw و transfer هر کدام یک اعتبارسنجی مشابه دارند:

def deposit(self, amount: float):
    if amount <= 0:
        raise ValueError("مبلغ واریز باید بیشتر از صفر باشد.")
    ...

def withdraw(self, amount: float):
    if amount <= 0:
        raise ValueError("مبلغ برداشت باید بیشتر از صفر باشد.")
    ...

def transfer(self, amount: float, target_wallet):
    if amount <= 0:
        raise ValueError("مبلغ انتقال باید بیشتر از صفر باشد.")
    ...

یک منطق، سه نسخه. این دقیقاً همان چیزی است که اصل DRY از آن جلوگیری می‌کند.

اصل DRY چیست؟

اصل «خودت را تکرار نکن» یا DRY یک اصل توسعه نرم‌افزار است که هدفش کاهش تکرار اطلاعاتی است که احتمال تغییرشان وجود دارد. این اصل می‌گوید: «هر قطعه از دانش باید یک نمایش واحد، بدون ابهام و معتبر در سیستم داشته باشد.» این اصل توسط Andy Hunt و Dave Thomas در کتاب The Pragmatic Programmer فرموله شده.

ترجمه ساده‌تر: هر منطق باید فقط یک بار نوشته شود.

چرا کد تکراری خطرناک است؟

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

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

تست‌ها را اجرا کنید

قبل از هر تغییر، مطمئن شوید همه تست‌ها سبز هستند:

pytest tests/test_wallet.py -v

مرحله اول: استخراج منطق مشترک

منطق اعتبارسنجی مبلغ یک متد مستقل می‌شود:

def _validate_positive_amount(self, amount: float, operation: str):
    if amount <= 0:
        raise ValueError(f"مبلغ {operation} باید بیشتر از صفر باشد.")

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

def deposit(self, amount: float):
    self._validate_positive_amount(amount, "واریز")
    self.balance += amount

def withdraw(self, amount: float):
    self._validate_positive_amount(amount, "برداشت")
    if amount > self.balance:
        raise ValueError("موجودی کافی نیست.")
    self.balance -= amount

def transfer(self, amount: float, target_wallet):
    self._validate_positive_amount(amount, "انتقال")
    ...

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

مرحله دوم: حذف عدد جادویی تکراری

یک نوع دیگر از تکرار در پروژه وجود دارد. نرخ کارمزد 0.01 ممکن است در چند جا ظاهر شود:

# در متد transfer
transaction_fee = amount * 0.01

# در متد batch_transfer
total_fee = total_amount * 0.01

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

class DigitalWallet:
    TRANSACTION_FEE_RATE = 0.01

    def _calculate_fee(self, amount: float) -> float:
        return amount * self.TRANSACTION_FEE_RATE

حالا اگر نرخ کارمزد تغییر کند، فقط یک خط در کل کلاس تغییر می‌کند.

تست‌ها در این ریفکتور چه نقشی دارند؟

تست‌هایی که قبلاً برای deposit، withdraw و transfer نوشتیم، هیچ‌کدام نمی‌دانند که _validate_positive_amount وجود دارد. آن‌ها فقط رفتار بیرونی را بررسی می‌کنند.

def test_deposit_raises_error_for_negative_amount():
    wallet = DigitalWallet("علی", 100.0)
    with pytest.raises(ValueError):
        wallet.deposit(-50)

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

یک هشدار درباره DRY

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

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

قانون عملی: وقتی یک منطق دقیقاً یکسان در بیش از دو جا ظاهر شد، وقت ریفکتور است. نه قبل از آن.

تست‌ها چه چیزی را تضمین می‌کنند و چه چیزی را نه

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

این جمله را دوباره بخوانید. مهم است.

آنچه تست واحد تضمین می‌کند

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

یعنی:

  • واریز ۵۰ تومان، موجودی را ۵۰ تومان افزایش می‌دهد 
    برداشت بیش از موجودی، ValueError می‌دهد 
    مبلغ منفی در deposit، خطا می‌دهد 

داشتن مجموعه جامعی از تست‌های واحد می‌تواند حس امنیت کاذب ایجاد کند. اینکه واحدهای جداگانه به درستی در ایزوله کار می‌کنند، تضمین نمی‌کند که کل سیستم آن‌طور که انتظار می‌رود کار کند.

آنچه تست واحد تضمین نمی‌کند

۱. تعامل بین بخش‌های مختلف

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

در پروژه کیف پول دیجیتال، تست واحد متد transfer تضمین می‌کند که محاسبات درست است. اما تضمین نمی‌کند که این متد با یک پایگاه داده واقعی یا یک سرویس خارجی درست کار می‌کند. این وظیفه تست یکپارچگی است.

۲. سناریوهایی که تست نشده‌اند

برخی مشکلات بسیار سخت برای یافتن هستند و ممکن است در حین تست ظاهر نشوند. مثال‌هایی از این باگ‌های «غیرقابل مشاهده» شامل مشکلات زمان‌بندی و همزمانی مثل race conditions هستند.

اگر تستی برای سناریوی «واریز مبلغ با اعشار بیش از دو رقم» ننوشته‌اید، تست‌ها آن رفتار را تضمین نمی‌کنند.

۳. کیفیت کد

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

۴. عملکرد و سرعت

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

خطرناک‌ترین اشتباه: تست غلط

بدترین نوع خطا، «مثبت کاذب» است؛ یعنی تستی که پاس می‌شود در حالی که نباید پاس شود. این تست نه تنها باگ را پنهان می‌کند، بلکه توسعه‌دهندگان آینده ممکن است آن را صحیح فرض کنند و خطا را به بخش‌های دیگر گسترش دهند.

یک مثال ساده از پروژه کیف پول دیجیتال:

# ❌ تست غلط: فقط چک می‌کند خطایی رخ داده، نه اینکه کدام خطا
def test_withdraw_with_insufficient_balance():
    wallet = DigitalWallet("علی", 50.0)
    try:
        wallet.withdraw(100.0)
    except:
        pass  # تست پاس می‌شود حتی اگر Exception اشتباه باشد!

# ✅ تست درست: رفتار دقیق بررسی می‌شود
def test_withdraw_raises_value_error_when_balance_insufficient():
    wallet = DigitalWallet("علی", 50.0)
    with pytest.raises(ValueError, match="موجودی کافی"):
        wallet.withdraw(100.0)

تست اول پاس می‌شود حتی اگر کد یک TypeError یا هر Exception دیگری بدهد. تست دوم دقیقاً نوع و پیام خطا را بررسی می‌کند.

پس تست‌ها چقدر به ما اطمینان می‌دهند؟

تست‌ها اطمینان می‌دهند، نه تضمین. تفاوت مهمی است.

یک مجموعه تست خوب می‌گوید: «در تمام سناریوهایی که تا الان فکرشان را کرده‌ام، کد درست رفتار می‌کند.» این ارزشمند است. اما فروتنی فکری هم لازم است؛ همیشه سناریوهایی وجود دارند که به ذهن نرسیده‌اند.

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

وقتی ریفکتور، تست را قرمز می‌کند

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

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

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

کد اصلی شما پس از اعمال این تغییر به شکل زیر در می‌آید:

class InsufficientFundsError(Exception):
    pass

class DigitalWallet:
    def __init__(self, balance: float):
        self.balance = balance

    def withdraw(self, amount: float):
        if amount > self.balance:
            raise InsufficientFundsError("موجودی کافی نیست.")
        self.balance -= amount

این تغییر ساختار کاملاً منطقی است و کد را حرفه‌ای‌تر می‌کند. بروز مشکل زمانی آشکار می‌شود که ابزار مدیریت تست پایتون یعنی pytest را اجرا می‌کنید. ترمینال به شما خطای شکست در اجرای تست را نمایش می‌دهد زیرا فایل تست‌های قدیمی شما هنوز با قوانین قبلی نوشته شده است.

تحلیل خطا و بازگرداندن نوار سبز رنگ در pytest

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

فایل تست قدیمی که باعث بروز خطا شده است، احتمالاً چنین ساختاری دارد:

import pytest
from wallet import DigitalWallet

def test_withdraw_error():
    wallet = DigitalWallet(100.0)
    with pytest.raises(ValueError):
        wallet.withdraw(150.0)

تست بالا شکست می‌خورد چون در خط آخر منتظر دریافت ValueError است، در حالی که کد ریفکتور شده شما اکنون خطای اختصاصی InsufficientFundsError را تولید می‌کند. برای حل این مشکل و بازگرداندن نوار سبز سلامت به پروژه، باید بلاک مدیریت استثنا را در فایل تست به‌روزرسانی کنید.

کد اصلاح‌شده تست به این صورت تغییر می‌یابد:

import pytest
from wallet import DigitalWallet, InsufficientFundsError

def test_withdraw_error_after_refactor():
    wallet = DigitalWallet(100.0)
    with pytest.raises(InsufficientFundsError):
        wallet.withdraw(150.0)

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

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

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

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