این مقاله ادامه مقاله قبلی است:
«چطور Test Smellها را در تستنویسی شناسایی و رفع کنیم؟(بخش اول)»
در بخش اول این مجموعه، تعدادی از Test Smellهای مهم مانند Assertion Roulette و Conditional Test Logic را بررسی کردیم. در این مقاله، قصد داریم سایر Test Smellهای رایج را بهصورت عملی معرفی کنیم و نحوه Refactor کردن آنها را با مثالهای PHP/Laravel نشان دهیم.
5- Duplicate Assert
Duplicate Assert یکی از Test Smellهای رایج است که زمانی اتفاق میافتد که چندین تست assertion مشابه یا یکسان در یک یا چند تست مختلف تکرار میشوند.
این تکرار میتواند به دو شکل باشد:
- درون یک تست (تکرار assertionهای مشابه در یک متد تست)
- در تستهای مختلف (چند تست جداگانه که همه یک منطق را تکرار میکنند)
چرا یک Smell محسوب میشود؟
- کاهش خوانایی تستها: وجود تکرارهای بیهوده، طول تست را زیاد میکند و فهم آن را سختتر.
- افزایش هزینه نگهداری: تغییر یک منطق تست باید در چند نقطه اعمال شود.
- ریسک بروز ناسازگاری: امکان دارد یک assertion در تستی تغییر کند اما در تستهای مشابه دیگر نه.
- افزایش زمان اجرای تستها: تکرار تستهای یکسان زمان اجرای تست را بیجهت زیاد میکند.
مثال
فرض کن در Laravel چندین تست نوشتهای که همه در حال assert گرفتن از یک شرط هستند:
تست اول:
public function test_user_is_premium()
{
$user = User::factory()->create([
'type' => 'premium',
]);
$this->assertEquals(
'premium',
$user->type,
'User should be premium.'
);
}
تست دوم:
public function test_premium_user_has_discount()
{
$user = User::factory()->create([
'type' => 'premium',
]);
$this->assertEquals(
'premium',
$user->type,
'User should be premium.'
);
$order = Order::factory()->create([
'user_id' => $user->id,
'discount' => 20,
]);
$this->assertGreaterThan(
10,
$order->discount,
'Premium users should receive more than 10% discount.'
);
}
در این مثال:
assertion $this->assertEquals('premium', $user->type) در هر دو تست تکرار شده است.
این یعنی Duplicate Assert رخ داده است.
راه حل
- ایجاد assertionهای مشترک در متدهای helper.
- یا استفاده از assertionهای custom.
هدف این است که اگر منطق assertion تغییر کرد، فقط یک نقطه برای تغییر وجود داشته باشد.
متد helper برای assert کردن نوع کاربر:
protected function assertUserIsPremium(User $user)
{
$this->assertEquals(
'premium',
$user->type,
'User should be premium.'
);
}
حالا تستها به این شکل تغییر میکنند:
public function test_premium_user_has_discount()
{
$user = User::factory()->create([
'type' => 'premium',
]);
$this->assertUserIsPremium($user);
$order = Order::factory()->create([
'user_id' => $user->id,
'discount' => 20,
]);
$this->assertGreaterThan(
10,
$order->discount,
'Premium users should receive more than 10% discount.'
);
}
اصل کلیدی:
در تستنویسی هم مانند کد production، اصل DRY را رعایت کن. تکرار assertionها را حذف کن و تستهای تمیز و قابل نگهداری بساز.
6- Eager Test
Eager Test یکی از Test Smellهای رایج است که زمانی اتفاق میافتد که یک تست واحد، چندین تابع، متد یا رفتار مستقل را همزمان بررسی میکند.
به بیان ساده:
- تست خیلی «حریص» (Eager) است و میخواهد همه چیز را یکجا تست کند.
- اما هر تست باید فقط یک رفتار (Single Responsibility) را بررسی کند.
چرا یک Smell محسوب میشود؟
- کاهش خوانایی تستها: تست طولانی میشود و هدف آن مبهم میگردد.
- تشخیص علت خطا دشوار میشود: اگر تست fail کند، معلوم نیست مشکل از کدام بخش است.
- تکرارپذیری کمتر تستها: نمیتوان همان تست را در شرایط دیگر بهکار برد.
- وابستگی تستها به یکدیگر: تست به چندین behavior گره میخورد که نباید با هم وابسته باشند.
- کاهش سرعت اجرای تستها: چون چندین کار را با هم انجام میدهد.
مثال
فرض کن در Laravel بخواهی ثبت سفارش و محاسبه تخفیف را تست کنی:
public function test_order_creation_and_discount_calculation()
{
$user = User::factory()->create([
'type' => 'premium',
]);
$order = Order::factory()->create([
'user_id' => $user->id,
'total_price' => 100,
]);
// بخش تست ثبت سفارش
$this->assertEquals(
$user->id,
$order->user_id,
'Order should belong to the user.'
);
$this->assertEquals(
100,
$order->total_price,
'Order total price should be 100.'
);
// بخش تست محاسبه تخفیف
$discountService = new DiscountService();
$discount = $discountService->calculate($order);
$this->assertGreaterThan(
10,
$discount,
'Premium users should receive more than 10% discount.'
);
}
این تست Eager Test است چون:
- هم ثبت سفارش را تست میکند.
- هم محاسبه تخفیف را.
- در حالی که این دو behavior کاملاً مستقل هستند.
اگر تست fail شود، نمیفهمیم مشکل در ثبت سفارش است یا در محاسبه تخفیف.
راه حل
- تفکیک تستها بر اساس Single Responsibility.
- هر تست باید فقط یک behavior را بررسی کند.
تست ثبت سفارش:
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 belong to the user.'
);
$this->assertEquals(
100,
$order->total_price,
'Order total price should be 100.'
);
}
تست محاسبه تخفیف:
public function test_premium_user_gets_discount()
{
$user = User::factory()->create([
'type' => 'premium',
]);
$order = Order::factory()->create([
'user_id' => $user->id,
'total_price' => 100,
]);
$discountService = new DiscountService();
$discount = $discountService->calculate($order);
$this->assertGreaterThan(
10,
$discount,
'Premium users should receive more than 10% discount.'
);
}
اصل کلیدی:
هر تست باید تنها یک رفتار مشخص را بررسی کند. تستهای چندکاره، دشمن تستنویسی تمیز هستند.
7- Empty Test
Empty Test یکی از سادهترین اما رایجترین Test Smellهاست که زمانی اتفاق میافتد که یک متد تست وجود دارد ولی هیچ assertion یا logic واقعی داخل آن نوشته نشده است.
یعنی تست ساخته شده، اما هیچ رفتاری را بررسی نمیکند و عملاً خالی است.
چرا یک Smell محسوب میشود؟
- فریبدهنده است: باعث میشود تصور کنیم بخشی از سیستم تست شده است، در حالی که در عمل هیچ تستی انجام نشده.
- حس امنیت کاذب ایجاد میکند: مخصوصاً وقتی تستها سبز (موفق) هستند ولی در واقع هیچ چیزی را assert نمیکنند.
- افزایش هزینه نگهداری: تیم توسعه به اشتباه فکر میکند یک feature تست شده و از آن غافل میشود.
- هدر دادن زمان: در اجرای تستها یا در خواندن آنها.
مثال
فرض کن در Laravel یک متد تست نوشتهای اما داخل آن چیزی قرار ندادهای یا حتی assertion ننوشتهای:
public function test_order_can_be_created()
{
// Test not implemented yet
}
یا حتی کمی بدتر، یک logic ناقص گذاشتهای اما assertion ننوشتهای:
public function test_user_can_be_created()
{
$user = User::factory()->create([
'name' => 'John Doe',
]);
// No assertion here!
}
این هم Empty Test محسوب میشود چون:
- تست اجرا میشود.
- اما چیزی را بررسی نمیکند.
- حتی اگر اجرا موفق باشد، هیچ تضمینی درباره عملکرد سیستم نمیدهد.
راه حل
- تستهای خالی را یا تکمیل کن یا حذف کن.
- اگر هنوز آماده نیست، حداقل یک fail موقت داخل تست قرار بده تا فراموش نشود.
تست ناقص بالا باید به این شکل اصلاح شود:
public function test_user_can_be_created()
{
$user = User::factory()->create([
'name' => 'John Doe',
]);
$this->assertEquals(
'John Doe',
$user->name,
'User name should be John Doe.'
);
}
اگر تست را هنوز نمیخواهی کامل کنی، از fail موقت استفاده کن تا تست سبز (موفق) ظاهر نشود:
public function test_order_can_be_created()
{
$this->fail('Test not implemented yet.');
}
اصل کلیدی:
هیچ تستی نباید فقط بهخاطر «وجود داشتن» نوشته شود؛ تست باید واقعاً چیزی را بررسی کند.
8- Exception Handling
Exception Handling یک Test Smell است که زمانی اتفاق میافتد که تستها مدیریت خطا یا Exception را به درستی بررسی نمیکنند یا شیوه تست کردن Exception در آنها نادرست است.
دو شکل رایج این smell:
- عدم بررسی Exception -> تست موفق اجرا میشود، اما هیچ تضمینی وجود ندارد که Exception مورد انتظار واقعاً اتفاق افتاده باشد.
- تست ناقص یا اشتباه Exception -> تست Exception را با کدهای شرطی یا try/catch دستی مدیریت میکند و fail یا pass شدن تست را وابسته به logic درونی میگذارد.
چرا یک Smell محسوب میشود؟
- تست ناکامل است: اگر Exception مورد انتظار پرتاب نشود، تست همچنان موفق میشود.
- وابستگی به logic اضافی: تست نباید شرط یا منطق اضافه داشته باشد تا متوجه شود Exception پرتاب شده یا نه.
- تشخیص علت خطا دشوار میشود: اگر Exception پرتاب شود اما بهدرستی تست نشود، علت خطا مبهم باقی میماند.
- حس امنیت کاذب: تست سبز است، در حالی که Exception مورد انتظار تست نشده.
مثال
فرض کن یک سرویس تخفیف داری که اگر قیمت منفی باشد، Exception اتفاق می افتد:
class DiscountService
{
public function calculate(Order $order)
{
if ($order->total_price < 0) {
throw new InvalidArgumentException('Total price cannot be negative.');
}
return 10;
}
}
حالا یک تست بد که smell دارد:
public function test_discount_service_throws_exception_on_negative_price()
{
$order = Order::factory()->make([
'total_price' => -100,
]);
try {
$service = new DiscountService();
$service->calculate($order);
$this->assertTrue(false, 'Exception was not thrown.');
} catch (InvalidArgumentException $e) {
$this->assertTrue(true);
}
}
مشکل این تست:
- اتکا کردن روی try/catch و logic داخلی.
- اگر Exception اتفاق نیفتد، تنها با یک assertFalse یا fail تشخیص داده میشود.
- خوانایی پایین است.
راه حل:
- از expectException در PHPUnit استفاده کن.
- این روش صریح، ساده و امن است.
تست صحیح به این شکل است:
public function test_discount_service_throws_exception_on_negative_price()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Total price cannot be negative.');
$order = Order::factory()->make([
'total_price' => -100,
]);
$service = new DiscountService();
$service->calculate($order);
}
اصل کلیدی:
تست نباید حدس بزند Exception اتفاق افتاده یا نه؛ باید صریحاً انتظارش را داشته باشد.
9- General Fixture
General Fixture یک Test Smell رایج است که زمانی رخ میدهد که تستها دادهها یا objectهایی را در setup خود ایجاد میکنند که تنها بخشی از آنها در تستها استفاده میشود.
به بیان ساده:
- fixture (دادههای تست) بیش از حد کلی و بزرگ ساخته میشود.
- بسیاری از اشیاء یا دادهها در تستها بدون استفاده باقی میمانند.
چرا یک Smell محسوب میشود؟
- مصرف منابع: دادهها یا اشیاء ساخته میشوند بدون اینکه مصرف شوند.
- کاهش سرعت اجرای تستها: fixtureهای بزرگ زمان بیشتری برای آمادهسازی میخواهند.
- کاهش خوانایی تستها: تشخیص اینکه هر تست به چه دادهای وابسته است، دشوار میشود.
- افزایش وابستگی غیرضروری: تستها به دادههایی گره میخورند که واقعاً نیاز ندارند.
- ریسک بروز باگهای مخفی: اگر fixture تغییر کند، ممکن است بهصورت ناخواسته بر تستهای غیرمرتبط تأثیر بگذارد.
مثال
فرض کن در کلاس تست، setup بزرگی نوشتهای:
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,
]);
}
حالا یک تست بسیار ساده داری:
public function test_user_is_premium()
{
$this->assertEquals(
'premium',
$this->user->type,
'User should be premium.'
);
}
در این مثال:
- تست فقط به $user نیاز دارد.
- اما $order و $cart هم ساخته شدهاند.
- این یعنی fixture بیش از حد کلی است -> General Fixture Smell.
راه حل
- fixtureها را اختصاصی بساز.
- فقط دادههایی را بساز که واقعاً در همان تست لازم هستند.
- اگر دادههای مشترک نیاز داری، متدهای helper کوچک بساز.
رویکرد fixture اختصاصی در هر تست:
public function test_user_is_premium()
{
$user = User::factory()->create([
'type' => 'premium',
]);
$this->assertEquals(
'premium',
$user->type,
'User should be premium.'
);
}
یا با helper:
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.'
);
}
اصل کلیدی:
در تستنویسی هم مثل production code، هر چیزی را فقط وقتی بساز که واقعاً به آن نیاز داری.
ادامه دارد…
در این مقاله به بررسی بخشی از Test Smellها پرداختیم. اما دنیای Test Smellها بسیار گستردهتر است و انواع دیگری از Test Smell وجود دارند که میتوانند کیفیت کد و تستهای شما را تحت تأثیر قرار دهند.
در ادامه، در بخش بعدی مقاله با عنوان:
«چطور Test Smellها را در تستنویسی شناسایی و رفع کنیم؟(بخش سوم)»
سایر Test Smellهای مهم را همراه با مثالها و راه حل ها بررسی خواهیم کرد.