پرش به مطلب اصلی

چرا یک ریفکتور سالم می‌تواند تست‌ها را بشکند؟

· ۴ دقیقه مطالعه
مهدی مالوردی
مهندس نرم‌افزار و نویسندهٔ این سایت

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

مقایسهٔ ماک و فیک در آزمون‌نویسی

نکتهٔ اصلی

اگر یک ریفکتور سالم باعث شکست تست شد، قبل از متهم کردن کد، اول از خودِ تست بپرسید: آیا واقعاً رفتار را می‌سنجد، یا به جزئیات پیاده‌سازی وابسته شده است؟

برای روشن‌تر شدن بحث، یک مثال ساده را در نظر بگیرید. فرض کنید کلاسی داریم به نام AccessManager. کار این کلاس فقط این است که تصمیم بگیرد یک کاربر دسترسی دارد یا نه؛ برای نمونه با تابعی مانند user_has_access(user_id).

class AccessManager:
def __init__(self, auth_service):
self.auth_service = auth_service

def user_has_access(self, user_id):
return self.auth_service.lookup_user(user_id) is not None

این کلاس برای تصمیم‌گیری به یک وابستگی بیرونی تکیه می‌کند: AuthorizationService. این سرویس تابعی مانند lookup_user(user_id) دارد. اگر کاربر مجاز باشد، یک User برمی‌گرداند و اگر مجاز نباشد، null.

در تست، نه می‌خواهیم و نه معمولاً می‌توانیم سرویس واقعی مجوزها را بالا بیاوریم. اینجا یکی از نخستین انتخاب‌ها، ماک است. با ماک معمولاً می‌گوییم اگر lookup_user("user123") فراخوانی شد، در یک حالت null برگردان و در حالت دیگر User. تا اینجا هنوز مشکل جدی‌ای نداریم؛ چون فقط رفتار این وابستگی را برای تست فراهم کرده‌ایم.

def test_returns_true_when_user_is_authorized():
auth = Mock()
auth.lookup_user.return_value = User("user123")

manager = AccessManager(auth)

assert manager.user_has_access("user123") is True

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

محل دردسر

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

اینجا همان نقطه‌ای است که یک ریفکتور سالم می‌تواند تست را بشکند، بی‌آن‌که سیستم خراب شده باشد. ریفکتور معمولاً نتیجه را عوض نمی‌کند؛ فقط مسیر رسیدن به نتیجه را تغییر می‌دهد. برای نمونه، فرض کنید یک کش ساده به AccessManager اضافه کنیم. اگر یک بار فهمیدیم user123 مجاز است، دیگر لازم نیست هر بار lookup_user("user123") را صدا بزنیم.

در این حالت، خروجیِ user_has_access("user123") همچنان درست است، اما تستی که اصرار داشت lookup_user حتماً فراخوانی شود، حالا شکست می‌خورد. آیا این شکست یعنی سیستم خراب شده است؟ نه. این فقط نشان می‌دهد که تست به شیوهٔ پیاده‌سازی گیر داده بود، نه به نتیجه‌ای که برای ما اهمیت دارد.

def test_checks_access_by_calling_lookup_user():
auth = Mock()
auth.lookup_user.return_value = User("user123")

manager = AccessManager(auth)
manager.user_has_access("user123")

auth.lookup_user.assert_called_once_with("user123")

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

class FakeAuthorizationService:
def __init__(self):
self.authorized_users = set()

def add_authorized_user(self, user_id):
self.authorized_users.add(user_id)

def lookup_user(self, user_id):
if user_id in self.authorized_users:
return User(user_id)
return None

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

  1. بررسی می‌کنیم user_has_access("user123") باید false باشد.
  2. کاربر را با چیزی مانند fake.add_authorized_user("user123") به فهرست کاربران مجاز اضافه می‌کنیم.
  3. دوباره بررسی می‌کنیم که این بار خروجی باید true باشد.
def test_changes_behavior_when_user_becomes_authorized():
fake = FakeAuthorizationService()
manager = AccessManager(fake)

assert manager.user_has_access("user123") is False

fake.add_authorized_user("user123")

assert manager.user_has_access("user123") is True

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

پیشنهاد عملی

اگر بین ماک و فیک مردد هستید، پیش‌فرض بهتر این است که از خودتان بپرسید: «می‌خواهم رفتار سیستم را تست کنم یا فقط مطمئن شوم یک فراخوانی مشخص رخ داده است؟» پاسخ این سؤال معمولاً انتخاب درست را روشن می‌کند.

جمع‌بندی من این است:

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

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