Sokan Academy

مقایسه سبک‌های London و Classical در Unit Testing

مقایسه سبک‌های London و Classical در Unit Testing

به‌طور کلی، یک unit test سه هدف اصلی را دنبال می‌کند:

  • یک بخش کوچک از کد (یک unit) را بررسی می‌کند
  • این کار را به‌سرعت انجام می‌دهد
  • این کار را به‌شکل ایزوله (isolated) انجام می‌دهد

در دنیای unit testing، دو مکتب فکری شکل گرفته‌اند که هر یک برداشت متفاوتی از معنی "ایزوله بودن" دارند.

مکتب لندن

مکتب London ایزوله بودن را این‌گونه تفسیر می‌کند: سیستم تحت تست باید از dependency‌های خود ایزوله شود.
در نتیجه، کدهایی که به این مکتب پایبند هستند، استفاده‌ی زیادی از mock‌ می‌کنند تا تعامل سیستم تحت آزمون با دیگر objectها را شبیه‌سازی کنند.

london

مکتب کلاسیک

در مکتب کلاسیک، ایزوله بودن به این معناست که خود تست‌ها باید از یکدیگر ایزوله باشند. در این کدها هم از mock استفاده می‌شود، اما نه برای mock کردن objectهای داخلی کد، بلکه فقط برای mock کردن dependency‌های مشترک (مثل دیتابیس یا شبکه).

classic

مثال

فرض کنید ما یک سیستم فروشگاهی داریم. وقتی کاربر سفارشی ثبت می کند، سیستم باید:

  • قیمت نهایی سفارش رو محاسبه کند
  • سفارش رو در دیتابیس ذخیره کند
  • یک ایمیل تایید برای مشتری بفرستد
class OrderService
{
    private PriceCalculator $calculator;
    private OrderRepository $repository;
    private Mailer $mailer;

    public function __construct(PriceCalculator $calculator, OrderRepository $repository, Mailer $mailer)
    {
        $this->calculator = $calculator;
        $this->repository = $repository;
        $this->mailer = $mailer;
    }

    public function placeOrder(array $items, string $email): bool
    {
        $total = $this->calculator->calculate($items);
        $this->repository->save($items, $total);
        $this->mailer->sendConfirmation($email, $total);

        return true;
    }
}

سبک London (همه چیز mock می‌شود)

در این سبک، فقط OrderService را تست می‌کنیم و همه سرویس ها یا Dependency های دیگر (calculator, repository, mailer) را mock می‌کنیم.

class OrderServiceLondonTest extends TestCase
{
    public function testPlaceOrderCallsDependenciesCorrectly()
    {
        $calculator = $this->createMock(PriceCalculator::class);
        $repository = $this->createMock(OrderRepository::class);
        $mailer = $this->createMock(Mailer::class);

        $calculator->expects($this->once())->method('calculate')->willReturn(100);
        $repository->expects($this->once())->method('save')->with(['item1'], 100);
        $mailer->expects($this->once())->method('sendConfirmation')->with('user@example.com', 100);

        $service = new OrderService($calculator, $repository, $mailer);
        $result = $service->placeOrder(['item1'], 'user@example.com');

        $this->assertTrue($result);
    }
}

مزایا

  • درصورتی که test fail شود دقیقا مشخص است که کدوم interaction  خراب شده است.
  • تست سریع اجرا می‌شود.

معایب

  • تست بسیار به implementation وابسته است
  • درصورتی که تغییری در ساختار internal  بدهید به عنوان مثال: ترتیب متد ها رو تغییر دهید یا پارامتر ها رو  تغییر دهید٬ test fail خواهد شد.

سبک Classical (فقط dependencyهای مشترک mock می شوند و  یا اصلاً mock نخواهیم داشت)

در مثال زیر٬ فقط از کلاس‌های واقعی استفاده می‌کنیم و سعی می‌کنیم فقط نتیجه نهایی را بررسی کنیم:

class OrderServiceClassicalTest extends TestCase
{
    public function testPlaceOrderActuallyPersistsAndSends()
    {
        $calculator = new PriceCalculator();
        $repository = new InMemoryOrderRepository();
        $mailer = new TestMailer(); 

        $service = new OrderService($calculator, $repository, $mailer);
        $result = $service->placeOrder(['item1'], 'user@example.com');

        $this->assertTrue($result);
        $this->assertEquals(1, count($repository->orders));
        $this->assertEquals('user@example.com', $mailer->lastEmail['to']);
    }
}

InMemoryOrderRepository و TestMailer کلاس‌هایی هستند که فقط برای تست نوشته شده‌اند و عملیات واقعی انجام نمی‌دهند (به عنوان مثال به دیتابیس متصل نمی شوند و یا ایمیل نمی‌فرستند).

مزایا

  • تست مستقل از پیاده‌سازی است
  • اگر implementation عوض شود ولی behavior ثابت بماند، تست همچنان سبز است
  • تست بیشتر شبیه استفاده‌ی واقعی از سیستم است

معایب

  • اگر fail شود، به سادگی نمی توان خطا را تشخیص داد. (آیا repository خراب شده است؟ یا mailer؟ یا calculator؟)

بحث پایانی

بیایید یادآوری کنیم که یک تست خوب چه ویژگی‌هایی دارد و این دو سبک را با آن بسنجیم:

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

  • نیاز به refactor تست هنگام تغییر کد
  • زمان اجرای تست
  • هشدارهای کاذب (false alarms) که تست ایجاد می‌کند
  • زمانی که صرف خواندن تست برای درک هدفش می‌شود

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

در نهایت، دو مورد باقی می‌ماند:

  • نیاز به refactor تست در هنگام تغییر کد
  • هشدارهای کاذب

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

این محتوا آموزنده بود؟
TDDunit testphp

sokan-academy-footer-logo
کلیه حقوق مادی و معنوی این وب‌سایت متعلق به سکان آکادمی می باشد.