Sokan Academy

چطور Test Smellها را در تست‌نویسی شناسایی و رفع کنیم؟(بخش اول)

چطور Test Smellها را در تست‌نویسی شناسایی و رفع کنیم؟(بخش اول)

مقدمه

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

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

Test Smell چیست؟

Test Smell یعنی یک ساختار یا الگوی خاص در کدهای تست که به تنهایی شاید باعث شکست تست نشود، اما باعث می‌شود تست‌ها:

  • سخت‌تر فهمیده شوند،
  • به‌سختی تغییر کنند،
  • یا خطاهای پنهانی داشته باشند.

Test Smell یک اشکال قطعی نیست، بلکه یک هشدار است که باید بررسی شود.

انواع Test Smells

در این بخش، شناخته‌شده‌ترین انواع Test Smells را به‌همراه مثال‌های از PHP/Laravel مرور می‌کنیم:


1- Assertion Roulette

Assertion Roulette یکی از متداول‌ترین Test Smells است که زمانی رخ می‌دهد که یک تست شامل چندین assertion است، بدون آن‌که هر assertion توضیح صریحی درباره هدف خود ارائه دهد.

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

چرا یک Smell محسوب می‌شود؟

  • در صورت بروز failure، پیام‌های خطا مبهم هستند و ارتباط بین خطا و منطق مورد تست مشخص نیست.
  • تست خوانایی و self-descriptiveness خود را از دست می‌دهد.
  • نگهداری و توسعه تست‌ها دشوارتر می‌شود، به‌ویژه در پروژه‌های تیمی یا پروژه‌هایی با چرخه عمر طولانی.

مثال
مثالی از یک تست در Laravel که دچار Assertion Roulette است:

public function test_order_process()
{
    $order = Order::factory()->create([
        'total_price' => 100,
        'status' => 'pending',
    ]);

    $this->assertEquals(100, $order->total_price);
    $this->assertEquals('pending', $order->status);
    $this->assertTrue($order->exists);
}

در این تست، در صورتی که یکی از assertions شکست بخورد، پیام PHPUnit به‌صورت زیر است:

Failed asserting that 'paid' matches expected 'pending'.

اما هیچ اطلاعاتی در مورد context این assertion ارائه نمی‌شود. توسعه‌دهنده نمی‌داند که این failure به کدام بخش از تست مربوط است یا چه شرط تجاری (business rule) نقض شده است.

راه حل
راه‌حل استاندارد برای رفع این smell، اضافه کردن پیام‌های توضیحی (message) به هر assertion است. در PHPUnit، آرگومان آخر متد assertionها معمولاً پیام است که می‌تواند علت و context تست را توضیح دهد.

public function test_order_process()
{
    $order = Order::factory()->create([
        'total_price' => 100,
        'status' => 'pending',
    ]);

    $this->assertEquals(
        100,
        $order->total_price,
        'Order total price should be 100 after creation.'
    );

    $this->assertEquals(
        'pending',
        $order->status,
        'Order status should initially be pending.'
    );

    $this->assertTrue(
        $order->exists,
        'Order record should exist in the database after creation.'
    );
}

در این حالت، در صورت بروز failure، پیام خطا واضح‌تر و قابل فهم‌تر خواهد بود:

Failed asserting that 'paid' matches expected 'pending'. Order status should initially be pending.

دلیل نام گذاری Assertion Roulette چیست؟

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

در Assertion Roulette هم همین اتفاق می‌افتد:
وقتی تست fail می‌شود، شما دقیقاً نمی‌دانید کدام assertion باعث شکست شده. مثل این است که توپ روی هر کدام از assertionها ممکن است «توقف کند» و خطا بدهد. در نتیجه، توسعه‌دهنده باید حدس بزند که failure مربوط به کدام بخش تست است.
این حالت شبیه بازی رولت است: نمی‌دانی توپ کجا می‌افتد!

2- Conditional Test Logic 

Conditional Test Logic یک Test Smell است که زمانی اتفاق می‌افتد که در کد تست از ساختارهای شرطی مانند if، else، switch یا حتی حلقه‌ها استفاده می‌شود تا تصمیم‌گیری کند کدام قسمت از تست اجرا شود.

چنین تستی در واقع چندین سناریو یا مسیر منطقی مختلف را در یک تست واحد ادغام می‌کند. این باعث می‌شود:

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

مثال

فرض کن در Laravel می‌خواهی وضعیت تخفیف را بر اساس نوع کاربر تست کنی. تست زیر smell دارد:

public function test_discount_logic()
{
    $user = User::factory()->create([
        'type' => 'premium',
    ]);

    $order = Order::factory()->create([
        'user_id' => $user->id,
        'discount' => 15,
    ]);

    if ($user->type === 'premium') {
        $this->assertGreaterThan(10, $order->discount);
    } else {
        $this->assertEquals(0, $order->discount);
    }
}

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

      1- کاربر Premium => انتظار تخفیف بیشتر از ۱۰ درصد
      2- کاربر عادی => بدون تخفیف

این باعث می‌شود:

  • هدف تست مبهم شود.
  • پوشش مسیر else در بسیاری از اجراها صفر باشد.

تست وابسته به داده‌های ورودی شود (در این مثال فقط کاربر Premium ساخته شده).

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

public function test_premium_user_gets_discount()
{
    $user = User::factory()->create([
        'type' => 'premium',
    ]);

    $order = Order::factory()->create([
        'user_id' => $user->id,
        'discount' => 15,
    ]);

    $this->assertGreaterThan(
        10,
        $order->discount,
        'Premium users should receive more than 10% discount.'
    );
}

و تست دیگر:

public function test_regular_user_gets_no_discount()
{
    $user = User::factory()->create([
        'type' => 'regular',
    ]);

    $order = Order::factory()->create([
        'user_id' => $user->id,
        'discount' => 0,
    ]);

    $this->assertEquals(
        0,
        $order->discount,
        'Regular users should not receive any discount.'
    );
}

اصل کلیدی:

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

3-Constructor Initialization

Constructor Initialization یکی از Test Smellهای رایج است که زمانی اتفاق می‌افتد که تست‌ها داده‌ها یا اشیاء مورد نیاز خود را مستقیماً داخل Constructor کلاس تست (یا setup اولیه) ایجاد و مقداردهی می‌کنند، بدون در نظر گرفتن این که ممکن است هر تست به داده‌های متفاوت یا سناریوهای متفاوتی نیاز داشته باشد.

به بیان دیگر، تمام اشیاء مورد نیاز برای همه تست‌ها به‌صورت پیش‌فرض در سازنده (Constructor) یا setUp() ساخته می‌شوند، حتی اگر برخی از آن‌ها در بسیاری از تست‌ها استفاده نشوند.

چرا یک Smell محسوب می‌شود؟

  • افزایش پیچیدگی Fixtureها: اشیاء اضافه در حافظه ساخته می‌شوند که بعضاً استفاده نمی‌شوند.
  • کاهش خوانایی تست: تشخیص اینکه کدام داده به کدام تست مربوط است دشوار می‌شود.
  • وابستگی غیرضروری بین تست‌ها: تغییر یک object در Fixture می‌تواند به‌طور ناخواسته روی سایر تست‌ها تأثیر بگذارد.
  • هدر دادن منابع: مخصوصاً در پروژه‌های بزرگ که objectهای پیچیده یا ارتباط با دیتابیس دارند.

مثال
فرض کن کلاس تستی در Laravel داری که تمام اشیاء را در متد setUp (که معادل Constructor در کلاس تست است) می‌سازد:

protected $user;
protected $order;
protected $cart;

protected function setUp(): void
{
    parent::setUp();

    $this->user = User::factory()->create([
        'type' => 'premium',
    ]);

    $this->order = Order::factory()->create([
        'user_id' => $this->user->id,
        'total_price' => 100,
    ]);

    $this->cart = Cart::factory()->create([
        'user_id' => $this->user->id,
    ]);
}

حالا فرض کن تست زیر تنها به $user نیاز دارد:

public function test_user_is_premium()
{
    $this->assertEquals(
        'premium',
        $this->user->type,
        'User should be premium.'
    );
}

اینجا Constructor Initialization Smell رخ داده است:

  • تست فقط به $user نیاز دارد.
  • ولی $order و $cart هم در setup ساخته شده‌اند و منابع اشغال می‌کنند.
  • فهمیدن وابستگی‌ها سخت‌تر می‌شود.

راه حل ها

راه‌حل اول:
        ۱- Fixtureها را minimal و اختصاصی نگه دار.
        ۲- تنها داده‌هایی را بساز که واقعاً در تست استفاده می‌شوند.

public function test_user_is_premium()
{
    $user = User::factory()->create([
        'type' => 'premium',
    ]);

    $this->assertEquals(
        'premium',
        $user->type,
        'User should be premium.'
    );
}

راه حل دوم:
        ۱- اگر تعداد زیادی تست دارید که داده‌های مشترک می‌خواهند، fixture را در متدهای helper قرار دهید.
        ۲- در غیر این صورت، Fixture هر تست را فقط در همان تست بسازید.

protected function createPremiumUser()
{
    return User::factory()->create([
        'type' => 'premium',
    ]);
}

public function test_user_is_premium()
{
    $user = $this->createPremiumUser();

    $this->assertEquals(
        'premium',
        $user->type,
        'User should be premium.'
    );
}

اصل کلیدی

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

4-Default Test

Default Test یکی از Test Smellهای رایج است که زمانی رخ می‌دهد که تست‌ها فقط سناریوهای پیش‌فرض یا شرایط نرمال سیستم را بررسی می‌کنند و هیچ تلاشی برای تست شرایط مرزی (edge cases)، خطاها یا ورودی‌های نامعتبر ندارند.

به‌بیان دیگر:

  • تست‌ها تنها در حال اثبات کارکرد سیستم در حالت خوشبینانه (happy path) هستند.
  • شرایط خاص، استثناها یا رفتار سیستم در شرایط غیرعادی (unhappy paths) بررسی نمی‌شود.

مثال

فرض کن یک سیستم ثبت سفارش در Laravel داری و تنها happy path را تست می‌کنی:

public function test_order_can_be_created()
{
    $user = User::factory()->create();

    $order = Order::factory()->create([
        'user_id' => $user->id,
        'total_price' => 100,
    ]);

    $this->assertEquals(
        $user->id,
        $order->user_id,
        'Order should be assigned to the user.'
    );

    $this->assertEquals(
        100,
        $order->total_price,
        'Order total price should be 100.'
    );
}

این تست تنها happy path را بررسی می‌کند:

  • کاربر معتبر است.
  • قیمت صحیح است.
  • همه چیز طبق انتظار پیش می‌رود.
  • اما چه اتفاقی می‌افتد اگر:
  • total_price منفی باشد؟
  • user_id نامعتبر باشد؟
  • فیلدی مثل status خالی باشد؟

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

راه حل

برای حذف Default Test Smell:

  • سناریوهای مرزی (edge cases) را شناسایی کن.
  • شرایط نامعتبر را شبیه‌سازی کن.
  • تست کن که سیستم رفتار صحیح در مواجهه با داده‌های غیرعادی دارد.

علاوه بر happy path، این تست‌ها را اضافه کن:
تست قیمت منفی:

public function test_order_creation_fails_with_negative_price()
{
    $user = User::factory()->create();

    $this->expectException(ValidationException::class);

    Order::factory()->create([
        'user_id' => $user->id,
        'total_price' => -50,
    ]);
}

تست user_id نامعتبر:

public function test_order_creation_fails_with_invalid_user_id()
{
    $this->expectException(QueryException::class);

    Order::factory()->create([
        'user_id' => 999999,
        'total_price' => 100,
    ]);
}

تست فیلد خالی:

public function test_order_creation_fails_with_missing_status()
{
    $user = User::factory()->create();

    $this->expectException(ValidationException::class);

    Order::create([
        'user_id' => $user->id,
        'total_price' => 100,
        // status intentionally missing
    ]);

اصل کلیدی:

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


ادامه دارد…

در این مقاله به بررسی بخشی از Test Smellها پرداختیم. اما دنیای Test Smellها بسیار گسترده‌تر است و انواع دیگری از Test Smell وجود دارند که می‌توانند کیفیت کد و تست‌های شما را تحت تأثیر قرار دهند.

در ادامه، در بخش بعدی مقاله با عنوان:
«چطور Test Smellها را در تست‌نویسی شناسایی و رفع کنیم؟(بخش دوم)»
سایر Test Smellهای مهم را همراه با مثال‌ها و راه حل ها بررسی خواهیم کرد.

این محتوا آموزنده بود؟
تست نویسیunit testبرنامه‌ نویسی

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