این مقاله به بررسی جامع و عمیق مفهوم Test Double در مهندسی نرمافزار میپردازد، ابزاری حیاتی که در قلب توسعه آزمونمحور (TDD) قرار دارد و نقش بنیادینی در خلق کدهای پاک، قابل نگهداری و عاری از خطا ایفا میکند.

در قلب فرایند توسعه نرمافزار مدرن، یونیت تستینگ (Unit Testing) قرار دارد. هدف اصلی آن، ایزوله کردن یک واحد کوچک از کد (معمولاً یک متد یا کلاس) و تأیید رفتار صحیح آن است. با این حال، در دنیای واقعی، واحدهای کد اغلب با وابستگیهای خارجی (Dependencies) در ارتباط هستند. این وابستگیها میتوانند شامل پایگاه داده، سرویسهای تحت شبکه، فایل سیستمها، یا حتی کلاسهای پیچیدهتر در خود سیستم باشند.
استفاده از این وابستگیهای واقعی در محیط تست، چندین چالش عمده ایجاد میکند که فرایند تست را کند، غیرقابل اعتماد و پر از عوارض جانبی ناخواسته میسازد. برای مثال:
کندی: تعامل با یک پایگاه داده یا برقراری تماسهای شبکه، زمانبر است. این امر، سرعت اجرای تستها را به شدت کاهش میدهد و چرخه بازخورد (feedback loop) را طولانی میکند، که برای توسعهدهنده مخرب است.
ناپایداری: سرویسهای خارجی ممکن است در دسترس نباشند یا به دلایل مختلف (مانند قطعی شبکه یا تغییر در API) رفتار غیرقابل پیشبینی داشته باشند. این امر منجر به تستهای شکننده (brittle) میشود که حتی بدون تغییر در کد مورد آزمایش، ممکن است با شکست مواجه شوند.
عوارض جانبی: اجرای یک تست نباید منجر به ارسال ایمیلهای واقعی، انجام تراکنشهای بانکی، یا تغییر دائمی در دادههای پایگاه داده شود. این عوارض، محیط تست را آلوده و مدیریت آن را دشوار میسازند.
دسترسی زودهنگام: گاهی اوقات، وابستگی مورد نیاز هنوز در حال توسعه است و وجود خارجی ندارد.
Test Double دقیقاً برای حل این مشکلات طراحی شده است. این اصطلاح، یک پوشش (umbrella term) برای هر شیئی است که در طول تست، جایگزین یک وابستگی واقعی میشود. هدف اصلی آن، جداسازی سیستم تحت تست (System Under Test - SUT) از وابستگیهایش است تا بتوان تنها رفتار داخلی SUT را با دقت و سرعت بالا مورد بالا مورد ارزیابی قرار داد.
جرارد مساروس و مارتین فاولر، با تفکیک دقیق انواع Test Double، ابهامات موجود در این حوزه را از بین بردند. هر یک از این انواع، هدف و کاربرد مشخصی دارند که در ادامه به تفصیل بررسی میشوند.
یک Dummy سادهترین شکل Test Double است. این شیء تنها یک محلنگهدار (placeholder) است و هیچ منطقی را پیادهسازی نمیکند.
توضیح دقیق: Dummy یک شیء یا یک مقدار است که به عنوان پارامتر به یک متد یا سازنده (constructor) پاس داده میشود، اما هرگز در طول اجرای تست از آن استفاده یا متدی از آن فراخوانی نمیشود. هدف اصلی آن، برآورده کردن امضای متد (method signature) و جلوگیری از خطای کامپایل است.
رفتار: کاملاً منفعل (passive) و بیاثر است. این شیء هیچ state یا رفتاری ندارد و صرفاً برای پر کردن لیست آرگومانها وجود دارد.
مورد استفاده: در سناریوهایی که سیستم تحت تست (SUT) دارای وابستگیهای متعددی است، اما منطق تستی که مینویسیم تنها به یکی از آنها مربوط میشود. برای وابستگیهای بیاهمیت، از Dummy استفاده میشود.
مثال کد در PHP:
فرض کنید یک کلاس OrderProcessor دارید که برای پردازش یک سفارش، نیاز به Order و User دارد، اما در یک تست خاص میخواهید فقط تأیید کنید که سفارش به درستی ذخیره میشود، بدون توجه به اینکه کدام کاربر آن را ثبت کرده است.
// کلاس OrderProcessor class OrderProcessor { public function process(Order $order, User $user) { // ... $order->save(); } } class User {} // dummy object // test public function testOrderIsSaved() { $order = new Order(); $dummyUser = new User(); // شیء ساختگی (Dummy) $processor = new OrderProcessor(); $processor->process($order, $dummyUser); }
Stubها Test Doubleهایی هستند که رفتاری از پیشبرنامهریزیشده و ثابت دارند.
توضیح دقیق: Stub یک پیادهسازی ساده و کنترلشده از یک وابستگی است که مقادیر از پیش تعریفشده (canned answers) را در پاسخ به فراخوانیهای متدهای خاص برمیگرداند. هدف اصلی آن، کنترل جریان داده به سیستم تحت تست (SUT) است. Stubها به ما اجازه میدهند تا ورودیهای غیرمستقیم را برای SUT فراهم کنیم و وضعیت (state) آن را مورد آزمایش قرار دهیم.
رفتار: Stubها هیچ منطق پیچیدهای ندارند. آنها به سادگی یک مجموعه از پاسخها را بر اساس فراخوانیهای مورد انتظار ذخیره میکنند و آنها را در زمان مناسب برمیگردانند.
مورد استفاده: زمانی که سیستم تحت تست، برای ادامه عملیات خود به دادههایی از یک وابستگی نیاز دارد. Stub به جای اتصال به وابستگی واقعی (مانند دیتابیس یا سرویس شبکه)، دادههای مورد نیاز تست را به سرعت و به صورت قابل پیشبینی فراهم میکند. این نوع Test Double در تأیید وضعیت (State-based Verification) به شدت مورد استفاده قرار میگیرد.
مثال کد در PHP:
فرض کنید یک کلاس GradeCalculator دارید که برای محاسبه میانگین نمرات یک دانشجو، به یک سرویس GradebookService وابسته است.
interface GradebookService { public function getGradesFor(int $studentId): array; } // کلاس مورد تست class GradeCalculator { private GradebookService $gradebookService; public function __construct(GradebookService $gradebookService) { $this->gradebookService = $gradebookService; } public function calculateAverage(int $studentId): float { $grades = $this->gradebookService->getGradesFor($studentId); if (empty($grades)) { return 0.0; } return array_sum($grades) / count($grades); } } // پیادهسازی Stub class GradebookServiceStub implements GradebookService { public function getGradesFor(int $studentId): array { // پاسخ از پیش تعیین شده if ($studentId === 123) { return [85, 90, 75]; } return []; } } // test public function testAverageCalculation() { $stub = new GradebookServiceStub(); $calculator = new GradeCalculator($stub); $average = $calculator->calculateAverage(123); assertEquals(83.33, round($average, 2)); }
Fake یک پیادهسازی سبکوزن و جایگزین از یک وابستگی واقعی است که منطق داخلی خود را دارد.
توضیح دقیق: Fakeها نسخههای ساده و بهینهسازیشده از وابستگیهای پیچیده هستند. برخلاف Stub که صرفاً پاسخهای از پیش تعیینشده را برمیگرداند، یک Fake دارای یک پیادهسازی عملیاتی است که رفتار واقعی شیء اصلی را شبیهسازی میکند، اما به گونهای که برای تست مناسبتر است (مثلاً سریعتر یا با عوارض جانبی کمتر). آنها state داخلی خود را مدیریت میکنند و میتوانند رفتارهای پیچیدهتر را از خود نشان دهند.
رفتار: Fakeها دارای منطق عملیاتی هستند. آنها state خود را تغییر میدهند و فراخوانیها را به صورت پویا پردازش میکنند. برای مثال، یک Fake Repository به جای اتصال به دیتابیس، دادهها را در یک آرایه درون حافظه ذخیره میکند.
مورد استفاده: زمانی که جایگزین کردن یک وابستگی با یک نسخه سبکتر و سریعتر، مزایای قابل توجهی را برای تستها فراهم میکند، مانند جایگزینی یک پایگاه داده سنگین با یک پایگاه داده درون حافظه.
مثال کد در PHP:
فرض کنید یک کلاس UserRepository دارید که وظیفه مدیریت کاربران را بر عهده دارد و به یک دیتابیس وابسته است.
interface UserRepository { public function save(User $user): void; public function findById(int $id): ?User; } class InMemoryUserRepository implements UserRepository { private array $users = []; public function save(User $user): void { $this->users[$user->getId()] = $user; } public function findById(int $id): ?User { return $this->users[$id] ?? null; } } // کلاس مورد تست class UserService { private UserRepository $userRepository; public function __construct(UserRepository $userRepository) { $this->userRepository = $userRepository; } public function registerNewUser(User $user): bool { if ($this->userRepository->findById($user->getId()) !== null) { return false; } $this->userRepository->save($user); return true; } } // test public function testUserRegistration() { $fakeRepository = new InMemoryUserRepository(); $userService = new UserService($fakeRepository); $newUser = new User(1, 'JohnDoe'); assertTrue($userService->registerNewUser($newUser)); assertNotNull($fakeRepository->findById(1)); }
Mock یک Test Double است که برای تأیید رفتار (Behavior Verification) سیستم تحت تست طراحی شده است.
توضیح دقیق: Mockها اشیائی هستند که با انتظارات از پیش برنامهریزی شدهاند. آنها نه تنها پاسخهای از پیش تعیینشده (مانند Stub) را برمیگردانند، بلکه فراخوانیهایی که از سیستم تحت تست دریافت میکنند را نیز ثبت میکنند. پس از اجرای SUT، میتوان Mock را مورد بررسی قرار داد تا مطمئن شد که SUT به درستی با آن تعامل کرده است.
رفتار: Mockها برای تأیید اینکه متدهای خاصی با تعداد دفعات مشخص و با پارامترهای صحیح فراخوانی شدهاند، مورد استفاده قرار میگیرند. آنها معمولاً با فریمورکهای Mocking مانند Mockito در جاوا یا Mockery در PHP ساخته میشوند.
مورد استفاده: زمانی که خروجی مستقیم یک متد مهم نیست، اما عوارض جانبی آن (یعنی فراخوانی متدهای دیگر روی وابستگیها) اهمیت دارد. این امر در تست کردن متدهای از نوع Command (متدهایی که وضعیت سیستم را تغییر میدهند و مقداری برنمیگردانند) بسیار رایج است.
مثال کد در PHP:
فرض کنید یک کلاس OrderService دارید که پس از ثبت سفارش، یک ایمیل تأیید برای مشتری ارسال میکند.
interface EmailService { public function sendEmail(string $to, string $subject, string $body): void; } // کلاس مورد تست class OrderService { private EmailService $emailService; public function __construct(EmailService $emailService) { $this->emailService = $emailService; } public function placeOrder(Order $order, User $user): void { $order->setStatus('PLACED'); $this->emailService->sendEmail( $user->getEmail(), 'Order Confirmation', 'Your order has been placed.' ); } } // test use Mockery; public function testOrderConfirmationEmailIsSent() { $mockEmailService = Mockery::mock(EmailService::class); $mockEmailService->shouldReceive('sendEmail') ->once() ->with('john@example.com', 'Order Confirmation', 'Your order has been placed.'); $orderService = new OrderService($mockEmailService); $user = (object) ['email' => 'john@example.com']; $order = new Order(); $orderService->placeOrder($order, $user); Mockery::close(); }
Spy یک Test Double هیبریدی است که رفتار یک شیء واقعی را رصد میکند.
توضیح دقیق: Spy به شما اجازه میدهد تا فراخوانیها به یک شیء واقعی را رصد کنید، بدون اینکه رفتار اصلی آن را کاملاً جایگزین کنید. این نوع Test Double، شیء اصلی را "دورپیچ" (wrap) میکند و تمام فراخوانیهای متد به آن را ثبت میکند. این کار به شما امکان میدهد تا متدهای خاصی را Stub کنید و همزمان، رفتار کلی شیء را تأیید کنید.
رفتار: Spyها قابلیتهای Stub و Mock را ترکیب میکنند. آنها میتوانند پاسخهای از پیش تعیینشده را برگردانند و همزمان، اطلاعاتی مانند تعداد دفعات فراخوانی و پارامترهای ارسالی را نیز ثبت کنند.
مورد استفاده: زمانی که میخواهید یک متد خاص را Stub کنید و همزمان تأیید کنید که متدهای دیگر روی همان شیء، به درستی فراخوانی شدهاند. این امر به ویژه زمانی مفید است که نمیخواهید کل شیء را Mock کنید، بلکه فقط به رصد یا تغییر رفتار یک متد خاص نیاز دارید.
مثال کد در PHP:
فرض کنید یک کلاس Mailer دارید که یک متد send برای ارسال ایمیل دارد. میخواهید در تست، تعداد دفعات فراخوانی این متد را بررسی کنید، بدون اینکه ایمیل واقعی ارسال شود.
class Mailer { public function send(string $to, string $subject, string $body): bool { echo "Sending email to {$to}\n"; return true; } } // کلاس مورد تست class NotificationService { private Mailer $mailer; public function __construct(Mailer $mailer) { $this->mailer = $mailer; } public function sendWelcomeEmail(string $email): bool { return $this->mailer->send($email, 'Welcome', 'Welcome to our service!'); } } // test public function testWelcomeEmailIsSent() { $realMailer = new Mailer(); // create spy $spy = createSpyOn($realMailer); $notificationService = new NotificationService($spy); $notificationService->sendWelcomeEmail('test@example.com'); // تأیید رفتار assertCalledOnce($spy->send); assertCalledWith($spy->send, 'test@example.com', 'Welcome', 'Welcome to our service!'); }
درک و بهکارگیری صحیح Test Double فراتر از یک تکنیک برنامهنویسی ساده است؛ این یک اصل بنیادین در توسعه نرمافزار مدرن است که به توسعهدهندگان قدرت و اعتماد به نفس لازم برای ایجاد کدهای تمیز، قابل نگهداری و باگ کمتر را میدهد. همانطور که یک بدلکار به کارگردان این امکان را میدهد که صحنههای پیچیده را بدون به خطر انداختن بازیگر اصلی ضبط کند، Test Double نیز به مهندسان نرمافزار اجازه میدهد تا منطق کسبوکار خود را در یک محیط کنترلشده و ایزوله آزمایش کنند.
استفاده از Test Double در فرایند TDD یک نمونه بارز از این فلسفه است. با ایجاد Mockها و Stubها، توسعهدهندگان میتوانند حتی قبل از آماده شدن وابستگیها، تستهای شکستخورده را بنویسند و به سرعت در چرخه "قرمز-سبز-بازآرایی" حرکت کنند. این رویکرد نه تنها سرعت توسعه را افزایش میدهد، بلکه با وادار کردن توسعهدهنده به طراحی کدهای با اتصال سست (loosely coupled)، به ایجاد معماریهای ماژولار و قابل انعطاف کمک میکند. در نهایت، Test Double نه تنها یک ابزار برای تست، بلکه یک کاتالیزور برای بهبود کیفیت و پایداری کلی سیستمهای نرمافزاری است.