Sokan Academy

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

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

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

در بخش اول این مجموعه، تعدادی از Test Smellهای مهم مانند Duplicate Assert و Eager Test را بررسی کردیم. در این مقاله، قصد داریم سایر Test Smellهای رایج را به‌صورت عملی معرفی کنیم و نحوه Refactor کردن آن‌ها را با مثال‌های PHP/Laravel نشان دهیم.

10- Ignored Test

Ignored Test یکی از Test Smellهای ساده اما خطرناک است که زمانی رخ می‌دهد که تست‌ها عمداً غیرفعال (Skip) شده‌اند یا علامت‌گذاری شده‌اند تا اجرا نشوند.

این تست‌ها به دلایل مختلفی غیرفعال می‌شوند:

  • ناقص بودن تست.
  • شکست موقت تست.
  • مشکل در محیط یا dependencyها.
  • عدم اولویت دادن به تکمیل تست.

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

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

مثال

فرض کن در PHPUnit یک تست را به دلایل موقتی غیرفعال کرده‌ای:

/**
 * @test
 * @skip
 */
public function order_should_apply_discount()
{
    $this->fail('Not implemented yet.');
}

یا از annotation مخصوص skip استفاده کرده‌ای:

/**
 * @test
 * @doesNotPerformAssertions
 */
public function order_should_apply_discount()
{
    // Logic not implemented yet.
}

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

public function test_order_should_apply_discount()
{
    $this->markTestSkipped('Waiting for discount logic implementation.');
}

همه این نمونه‌ها یعنی تست Ignored Test است.

راه حل

برای مدیریت صحیح این حالت:

  • اگر تست ناقص است، آن را کامل کن.
  • اگر به‌طور موقت غیرفعال شده، با توضیح دقیق دلیل و یک یادآور (TODO) مشخص کن.
  • اگر نیاز به آماده‌سازی dependency دارد، با Mock یا Stub جایگزین کن.
  • اگر کاملاً بی‌استفاده است، حذف کن تا شفافیت حفظ شود.

تست را کامل بنویس تا اجرا شود:

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

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

    $discountService = new DiscountService();

    $discount = $discountService->calculate($order);

    $this->assertEquals(
        20,
        $discount,
        'Premium user should get 20% discount.'
    );
}

یا اگر هنوز آماده نیست، حتماً fail کن تا فراموش نشود:

public function test_order_should_apply_discount()
{
    $this->fail('Discount logic not implemented yet.');
}

اصل کلیدی:

هیچ تستی نباید بی‌صدا نادیده گرفته شود. یا آن را تکمیل کن، یا عمداً fail کن تا در چرخه توسعه فراموش نشود.

11- Lazy Test

Lazy Test یکی از Test Smellهای رایج است که زمانی رخ می‌دهد که یک تست، بسیار ساده و سطحی است و در واقع چیزی را به‌طور جدی بررسی نمی‌کند یا ارزش افزوده‌ای برای پروژه ندارد.

به بیان ساده:

  • تست «تنبل» است.
  • فقط بخش‌های سطحی یا بدیهی را تست می‌کند.
  • هیچ‌گونه سناریو واقعی یا edge case را نمی‌سنجد.

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

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

مثال

فرض کن یک متد تست ساده داری که فقط بررسی می‌کند یک مدل ساخته می‌شود:

public function test_user_factory_creates_instance()
{
    $user = User::factory()->make();

    $this->assertInstanceOf(User::class, $user);
}

مشکل این تست:

  • فقط بررسی می‌کند که factory یک instance بسازد.
  • اصلاً مهم نیست چه داده‌هایی در مدل وجود دارد یا چه behavior ای باید کار کند.
  • احتمال بروز باگ را صفر نمی‌کند.

راه حل

  • تست باید رفتار واقعی (behavior) را بررسی کند.
  • داده‌های کلیدی یا قوانین بیزینسی را تست کن.
  • از assertionهای meaningful استفاده کن.
  • edge caseها را در نظر بگیر.

راه حل پیشنهادی:

public function test_user_factory_creates_premium_user()
{
    $user = User::factory()->create([
        'type' => 'premium',
        'email' => 'john@example.com',
    ]);

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

    $this->assertEquals(
        'john@example.com',
        $user->email,
        'User email should be correctly assigned.'
    );
}

حالا:

  • behavior واقعی تست شده است.
  • داده‌ها معنا دارند.
  • اگر factory یا model تغییر کند، این تست fail خواهد شد.

اصل کلیدی:

تست‌ها باید باگ پیدا کنند. تست تنبل، تستی است که هیچ چیزی را کشف نمی‌کند.

12- Magic Number Test

Magic Number Test یکی از Test Smellهای رایج است که زمانی رخ می‌دهد که اعداد ثابت (literal values) بدون معنی یا توضیح در تست‌ها استفاده می‌شوند.

این اعداد معمولاً به‌صورت مستقیم در assertionها نوشته می‌شوند، بدون اینکه معلوم باشد:

  • این عدد چه معنایی دارد؟
  • چرا دقیقاً این عدد باید باشد؟
  • وابستگی آن به بیزینس چیست؟

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

  • کاهش خوانایی تست‌ها: شخص دیگری که تست را می‌خواند، متوجه نمی‌شود چرا مثلاً 42 انتخاب شده.
  • سختی نگهداری: اگر logic تغییر کند، باید همه مقادیر hardcoded را پیدا و تغییر دهی.
  • وابستگی مخفی به بیزینس: تست‌ها به logic بیزینسی وابسته می‌شوند، اما این وابستگی شفاف نیست.
  • اشتباهات انسانی: اعداد تکراری بدون معنای مشترک، ریسک اشتباه را بالا می‌برند.

مثال

فرض کن در Laravel تست تخفیف نوشته‌ای که به‌صورت hardcoded مقدار discount را assert می‌کند:

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

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

    $discountService = new DiscountService();

    $discount = $discountService->calculate($order);

    $this->assertEquals(
        40,
        $discount,
        'Premium users should receive discount.'
    );
}

مشکل این تست:

  • عدد ۴۰ به‌صورت مستقیم در assertion نوشته شده است.
  • معلوم نیست این عدد از کجا آمده:
  • ۴۰ درصد است؟
  • مبلغ ۴۰ هزار تومان است؟
  • یک ثابت قراردادی است؟
  • راه حل
  • اعداد مهم را در متغیر یا constant قرار بده.
  • نام این constant باید معنای بیزینسی عدد را نشان دهد.
  • اگر عدد از config می‌آید، تست هم باید از همان config بخواند.

نعریف constant در تست:

private const PREMIUM_DISCOUNT_AMOUNT = 40;

و تغییر تست:

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

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

    $discountService = new DiscountService();

    $discount = $discountService->calculate($order);

    $this->assertEquals(
        self::PREMIUM_DISCOUNT_AMOUNT,
        $discount,
        'Premium users should receive discount.'
    );
}

یا استفاده از config:

public function test_premium_user_gets_discount()
{
    $expectedDiscount = config('discount.premium');

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

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

    $discountService = new DiscountService();

    $discount = $discountService->calculate($order);

    $this->assertEquals(
        $expectedDiscount,
        $discount,
        'Premium users should receive discount as defined in config.'
    );
}

اصل کلیدی:

هیچ عددی نباید جادویی باشد؛ هر عدد در تست باید معنای بیزینسی داشته باشد.

13-  Mystery Guest

Mystery Guest یکی از Test Smellهای مهم است که زمانی رخ می‌دهد که تست‌ها برای اجرا یا صحت خود، به داده‌ها یا منابعی تکیه می‌کنند که خارج از scope یا context تست هستند و در خود تست به‌وضوح مشخص نشده‌اند.

به بیان ساده:

  • داده‌های مخفی یا نامرئی در تست وجود دارد.
  • تست برای عبور، به منابعی وابسته است که هیچ اثری از آن‌ها در تست دیده نمی‌شود.
  • خواننده تست متوجه نمی‌شود این داده‌ها یا منابع از کجا آمده‌اند.

مثال

فرض کن در پروژه Laravel، در دیتابیس یک کاربر با ایمیل خاص وجود دارد. تستت به‌طور مخفی به این کاربر وابسته است:

public function test_premium_user_can_access_dashboard()
{
    // هیچ factory یا setup انجام نشده است!

    $user = User::where('email', 'john@example.com')->first();

    $response = $this->actingAs($user)->get('/dashboard');

    $response->assertStatus(200);
}

مشکل این تست:

  • به کاربری با ایمیل خاص وابسته است.
  • این کاربر قبلاً به‌صورت دستی در دیتابیس ایجاد شده یا در fixtureهای عمومی وجود دارد.
  • اگر دیتابیس خالی باشد، تست fail می‌شود.
  • تست از Mystery Guest استفاده می‌کند.

راه حل

  • داده‌های لازم را به‌صورت صریح در خود تست بساز.
  • یا fixture را به وضوح در تست تعریف کن.
  • تست را از وابستگی به داده‌های محیطی یا مخفی جدا کن.

ساخت داده داخل تست:

public function test_premium_user_can_access_dashboard()
{
    $user = User::factory()->create([
        'email' => 'john@example.com',
        'type' => 'premium',
    ]);

    $response = $this->actingAs($user)->get('/dashboard');

    $response->assertStatus(200);
}

حالا:

  • تست کاملاً مستقل است.
  • هیچ داده مخفی یا Mystery Guest وجود ندارد.
  • روی هر محیط یا دیتابیس خالی هم اجرا می‌شود.

اصل کلیدی:

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

14- Redundant Print

Redundant Print یکی از Test Smellهای بسیار رایج است که زمانی رخ می‌دهد که در تست‌ها از دستورهای چاپ (مثل echo، print_r، var_dump) یا logهای غیرضروری استفاده می‌شود.

این دستورات اغلب به قصد دیباگ کردن اضافه می‌شوند، اما در کد نهایی تست باقی می‌مانند.

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

  • ایجاد نویز در خروجی تست‌ها: تست‌ها باید خروجی ساده و شفاف داشته باشند؛ چاپ‌های اضافی باعث شلوغی و سردرگمی می‌شود.
  • حس امنیت کاذب: برنامه‌نویس فکر می‌کند تست کامل است چون چیزی در خروجی دیده می‌شود، در حالی که تست واقعاً assert خاصی ندارد.
  • کاهش سرعت تست‌ها: مخصوصاً وقتی داده‌های حجیم چاپ شوند.
  • مشکل در CI/CD: خروجی‌های غیرضروری لاگ‌های CI را بی‌دلیل بزرگ می‌کند.
  • نشانه‌ی ضعف در assertion: اگر نیاز به چاپ داده داری، احتمالاً assertion تست کافی نیست.

مثال

فرض کن تست زیر را نوشته‌ای:

public function test_order_total_is_correct()
{
    $order = Order::factory()->create([
        'total_price' => 150,
    ]);

    echo $order->total_price;

    $this->assertEquals(150, $order->total_price);
}

یا حتی:

public function test_user_data()
{
    $user = User::factory()->create([
        'email' => 'john@example.com',
    ]);

    var_dump($user->toArray());
}

مشکل این تست‌ها:

  • استفاده از echo یا var_dump برای نمایش داده‌ها.
  • در واقع هیچ کمک خاصی به تست نمی‌کنند.
  • لاگ‌های CI/CD را بیهوده طولانی می‌کنند.

راه حل

  • تمام printها را حذف کن.
  • اگر می‌خواهی چیزی را بررسی کنی، assertion مناسب بنویس.
  • اگر چاپ برای دیباگ است، آن را موقت بگذار و بعد حذف کن.
  • برای داده‌های پیچیده، از assertionهای مخصوص (مثل assertJson) استفاده کن.
public function test_user_data()
{
    $user = User::factory()->create([
        'email' => 'john@example.com',
    ]);

    $this->assertEquals(
        'john@example.com',
        $user->email,
        'User email should match.'
    );
}

یا

public function test_api_returns_correct_data()
{
    $response = $this->get('/api/users');

    $response->assertJson([
        ['email' => 'john@example.com'],
    ]);
}

اصل کلیدی:

تست نباید فقط چیزی چاپ کند؛ باید چیزی را assert کند.


ادامه دارد…

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

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

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

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