Test Double چیست به همراه آموزش انواع Test Double

Test Double چیست به همراه آموزش انواع Test Double

اگر اخیرا در توسعه پروژه خود از روش TDD - Test Driven Development استفاده کرده اید اهمیت unit test آشنا شدید و یک تست unit نوشتید احتمالا نیاز به تغییر مقدار برگردانده شده از یک متد یا جلوگیری از اجرای واقعی یک شی را احساس کردید. برای انجام کارهایی از این دست، test double ها که در این مطلب با آنها آشنا می شویم به کمک ما می آیند.

test double چیست؟

زمانی که برای یک متد از کلاسی، unit test می نویسیم احتمال اینکه آن متد به اشیا دیگری در برنامه ما وابسته باشد زیاد است. به عنوان مثال متد زیر را در نظر بگیرید.

public function someThing(A $a, B $b)
{
  // does something that needs to be tested
}

در مثال بالا واضح است که متد someThing به کلاس های A و B وابسته است و احتمالا کدی که داخل متد something می نویسیم متد هایی از این کلاس ها را صدا می زند. هنگام نوشتن تست برای متد something باید اشیائی ساختگی از کلاس های A و B را به آن بدهیم. چرا که هدف ما تست کردن کلاس های A و B نیست. به این اشیا ساختگی اصطلاحا test doubles گفته می شود.

به طور کلی می توان گفت test double شی ساختگی ای است که هنگام تست آن را به جای شی اصلی قرار می دهیم.

در ادامه با انواع test double ها آشنا خواهیم شد.

انواع test double ها

هنگامی که تست می نویسیم، به کلاس یا بخشی از برنامه که عملکرد آن را می خواهیم آزمایش کنیم System under test (سیستم مورد آزمایش) می گوییم. در اکثر مواقع این کلاس را هنگام تست نویسی تغییر نمی دهیم بلکه وابستگی های این کلاس است که با کمک test double ها تغییر می کند.

فریم ورک قدرتمند PHPUnit (برای آموزش این فریمورک میتوانید به دوره Unit Test مراجعه بفرمایید) در زبان PHP متد هایی برای ایجاد test double ها در اختیار ما قرار می دهد. همچنین پکیج هایی مانند Mockery نیز برای انجام این کار وجود دارند. البته در اکثر مواقع نیازی به استفاده از آنها نیست. در این مطلب با استفاده از زبان PHP به راحتی انواع مختلف test double ها را می سازیم.

Dummy

Dummy، ساده ترین نوع test double است. چرا که تنها باید نیاز هایی که در سیستم مورد آزمایش، type hint شده است را برطرف کند. برای مثال کلاس زیر را در نظر بگیرید که وظیفه آن تعیین در دسترس بودنLive Chat  در سایت است. میخواهیم chat سایت تنها در ساعات کاری در دسترس باشد و همچنین از کلاس دیگری استفاده می کنیم تا مطمئن شویم در ساعات کاری تنها وقتی chat در دسترس است که مسئولان پشتیبانی در سایت حاضر اند.

class ChatAvailabilityCalculator

{
    private $openTime;
    private $closeTime;
    private $currentTime;
    private $agentService;

    public function __construct(
        DateTime $openTime,
        DateTime $closeTime,
        DateTime $currentTime,
        AgentService $agentService
    )
    {
        $this->openTime = $openTime;
        $this->closeTime = $closeTime;
        $this->currentTime = $currentTime;
        $this->agentService = $agentService;
    }

    public function isChatAvailable(): bool
    {
        $afterOpen = ($this->currentTime >= $this->openTime);
        $beforeClose = ($this->currentTime <= $this->closeTime);
        $inBusinessHours = ($afterOpen && $beforeClose);
     
        if (!$inBusinessHours) {
            return false;
        }
        
        return $this->agentService->agentsPresent();
    }
}

چند تست برای متد isChatAvailable می نویسیم که سناریو های زیر را پوشش می دهد:

  • در ساعات غیر کاری false برگرداند.
  • هنگامی که هیچ پشتیبانی حاضر نیست false برگرداند.
  • در ساعات کاری true برگرداند.

 اولین تست، به یک Dummy نیاز دارد چرا که ما هیچ نیازی  به کلاس AgentService نداریم اما باید آن را در constructor به عنوان ورودی بدهیم. AgentService یک اینترفیس ساده است.

interface AgentService
{
    public function agentsPresent(): bool;
}

به لطف کلاس های ناشناس در PHP ساخت Dummy بسیار ساده است.

/** @test */
public function When_its_outside_of_business_hours_chat_is_not_available()
{
    $openTime    = new DateTime('8:00 am');
    $closeTime   = new DateTime('5:00 pm');
    $currentTime = new DateTime('6:00 pm');

    $agentService = new class implements AgentService
    {
        public function agentsPresent(): bool
        {
            return false;
        }
    };

    $calculator = new ChatAvailabilityCalculator($openTime,
        $closeTime,
        $currentTime,
        $agentService);
    $this->assertFalse($calculator->isChatAvailable());
}

در کلاس ناشناسی که نوشتیم باید متد agentsPresent را می نوشتیم تا نیاز های اینترفیس AgentService را برطرف کند. مقدار برگشتی از تابع agentsPresent اهمیتی ندارد زیرا در این تست هیچ استفاده ای از آن نخواهد شد.

Stub

Stub هنگامی استفاده می شود که یک Dummy نیاز دارد مقدار خاصی را برای تست برگرداند. اکنون تست متد isChatAvailable را برای زمانی که پشتیبانی حضور ندارد می نویسیم. در این تست مقدار برگردانده شده از متد agentsPresent مهم است. چرا که اگر false یا true برگرداند روی تست ما تاثیر می گذارد. بنابراین هنگامی که در Dummy نوشته شده، مقدار برگشتی از متد مهم است به آن Stub می گوییم.

/** @test */
public function when_no_agent_is_present_chat_is_not_available()
{
    $openTime    = new DateTime('8:00 am');
    $closeTime   = new DateTime('5:00 pm');
    $currentTime = new DateTime('4:00 pm');

    $agentService = new class implements AgentService
    {
        public function agentsPresent(): bool
        {
            return false;
        }
    };

    $calculator = new ChatAvailabilityCalculator($openTime,
        $closeTime,
        $currentTime,
        $agentService);
    $this->assertFalse($calculator->isChatAvailable());
}

تست دیگری می نویسیم و این بار مقدار stub را true قرار می دهیم.

/** @test */
public function when_agents_are_present_chat_is_available()
{
    $openTime    = new DateTime('8:00 am');
    $closeTime   = new DateTime('5:00 pm');
    $currentTime = new DateTime('4:00 pm');

    $agentService = new class implements AgentService
    {
        public function agentsPresent(): bool
        {
            return true;
        }
    };

    $calculator = new ChatAvailabilityCalculator($openTime,
        $closeTime,
        $currentTime,
        $agentService);
    $this->assertTrue($calculator->isChatAvailable());
}

Spy

این نوع از test double نحوه ی استفاده از شی را ضبط می کند و سپس این اطلاعات را در اختیار شما قرار می دهد تا بر مبنای آن تست بنویسید. معمولا هنگامی از Spy استفاده می شود که از بیشتر متد های یک کلاس استفاده می کنیم اما نیاز است که بدانیم بعضی از متد های آن  صدا زده شدند یا مقدار خاصی را به عنوان ورودی دریافت کردند. این نوع از test double ها با Mock شباهت زیادی دارند.

 برای مثال کلاسی را در نظر بگیرید که با استفاده از کلاس User و اینترفیسی برای ذخیره کاربر، آن را ثبت نام می کند. می خواهیم تست کنیم که برای هر آرگومان مقادیر درستی قرار داده می شود و کلاس User به درستی به اینترفیس ذخیره کاربر، پاس داده می شود.

class User
{
    public $username;
    public $emailAddress;
    public $favoriteColor;

    public function __construct(
        string $username,
        string $emailAddress,
        string $favoriteColor
    )
    {
        $this->username = $username;
        $this->emailAddress = $emailAddress;
        $this->favoriteColor = $favoriteColor;
    }
}

class UserRegistration
{
    private $persistenceDriver;

    public function __construct(UserPersistenceDriver $driver)
    {
        $this->persistenceDriver = $driver;
    }

    public function register(
        string $username,
        string $favoriteColor,
        string $emailAddress
    ): Response
    {
        $user = new User($username, $emailAddress, $favoriteColor);
        $this->persistenceDriver->save($user);
        return new Response(201);
    }
}

برای نوشتن تست، یک کلاس می سازیم که از اینترفیس UserPersistenceDriver پیروی می کند و هنگامی که به متد آن ورودی داده می شود، آن را ضبط می کند تا بعدا از آن استفاده کنیم.

class UserRegistrationTest extends TestCase
{
    /** @test */
    public function register_sends_user_to_persistence()
    {
        $username      = "j.doe";
        $favoriteColor = 'blue';
        $emailAddress  = 'jdoe@example.com';

        $driver = new class implements UserPersistenceDriver
        {
            public $user;
            public $called = false;

            public function save(User $user): bool
            {
                $this->called = true;
                $this->user   = $user;
                return true;
            }
        };

        $action = new UserRegistration($driver);
        $action->register($username, $favoriteColor, $emailAddress);

        $user = $driver->user;
        $this->assertTrue($driver->called);
        $this->assertEquals($user->username, $username);
        $this->assertEquals($user->favoriteColor, $favoriteColor);
        $this->assertEquals($user->emailAddress, $emailAddress);
    }
}

در این تست کلاس UserPersistenceDriver به عنوان Spy، کلاس User ارسال شده به متد save را نگه می دارد تا بعدا از آن در تست خود استفاده کنیم.

Spy ها، هنگامی که می خواهیم اطمینان حاصل کنیم که یک متد هرگز صدا زده نمی شود نیز مفید اند. در مثال بالا به متد register، ارزیابی اضافه می کنیم تا مطمئن باشیم رنگ مورد علاقه کاربر در لیست رنگ های معتبر برای ما قرار دارد. متد register را به شکل زیر تغییر می دهیم.

public function register(string $username, string $favoriteColor, string $emailAddress): Response
{
    $colors = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
    if (in_array($favoriteColor, $colors, true) === false) {
        return new Response(422);
    }
    
    $user = new User($username, $emailAddress, $favoriteColor);
    $this->persistenceDriver->save($user);
    
    return new Response(201);
}

یک Spy جدید می نویسیم که تنها صدا زده شدن متد save را ضبط می کند و پس از آن تستی می نویسیم تا اطمینان حاصل کند که متد save صدا نشده است.

/** @test */
public function register_does_not_persist_user_when_color_is_invalid()
{
    $username      = "j.doe";
    $favoriteColor = 'rainbow';
    $emailAddress  = 'jdoe@example.com';

    $driver = new class implements UserPersistenceDriver
    {
        public $called = false;

        public function save(User $user): bool
        {
            $this->called = true;
            return true;
        }
    };

    $action = new UserRegistration($driver);
    $action->register($username, $favoriteColor, $emailAddress);

    $this->assertFalse($driver->called);
}

Mock

Mock ها نوع خاصی از test double اند که انتظاراتی از صدا زده شدن یا نشدن یک متد دارند. همچنین آنها می توانند درمورد ورودی ها و خروجی های یک متد نیز انتظاراتی را تعریف کنند. درصورتی که یکی از انتظارات Mock برآورده نشود یک Exception را throw می کند که باعث fail شدن تست ما می شود. یکی از تفاوت های بزرگی که هنگام تست نویسی با mock ها، متوجه آن خواهید شد این است که Mock ها مانند سایر assertion ها ادعایی را اثبات نمی کنند بلکه انتظاراتی را تعیین می کنند که در صورت برآورده نشدن آن ها تست fail می شود. برای نشان دادن Mock از کد های مثال قبل استفاده می کنیم و این بار با Mock، ورودی های متد save را ارزیابی کرده و توسط متد destruct در صورتی که متد save هرگز صدا نشده باشد یک Exception را throw می کنیم.

/** @test */
public function register_sends_user_to_persistence()
{
    $username      = "j.doe";
    $favoriteColor = 'blue';
    $emailAddress  = 'jdoe@example.com';

    $driver = new class($username, $favoriteColor, $emailAddress) implements
        UserPersistenceDriver
    {
        public $called = false;

        public $username;
        public $favoriteColor;
        public $emailAddress;

        public function __construct($username, $favoriteColor, $emailAddress)
        {
            $this->username      = $username;
            $this->emailAddress  = $emailAddress;
            $this->favoriteColor = $favoriteColor;
        }

        public function save(User $user): bool
        {
            $this->called = true;
            if ($user->username !== $this->username) {
                throw new Exception("Expected Username: {$this->username}, found: {$user->username}");
            }
            if ($user->emailAddress !== $this->emailAddress) {
                throw new Exception("Expected Email Address: {$this->emailAddress}, found: {$user->emailAddress}");
            }
            if ($user->favoriteColor !== $this->favoriteColor) {
                throw new Exception("Expected Favorite Color: {$this->favoriteColor}, found: {$user->favoriteColor}");
            }
            return true;
        }

        public function __destruct()
        {
            if ($this->called !== true) {
                throw new Exception('Save method was not called');
            }
        }
    };

    $action = new UserRegistration($driver);
    $action->register($username, $favoriteColor, $emailAddress);
}

بررسی اینکه متدی هرگز صدا نشده باشد با Mock کمی راحت تر است. تست زیر اطمینان حاصل می کند که متد save در صورت اشتباه بودن داده های کاربر صدا نمی شود.

/** @test */
public function register_does_not_persist_user_when_color_is_invalid()
{
    $username = "j.doe";
    $favoriteColor = 'rainbow';
    $emailAddress = 'jdoe@example.com';

    $driver = new class implements UserPersistenceDriver {
        public $called = false;

        public function save(User $user): bool
        {
            throw new Exception('save method was called');
        }
    };

    $action = new UserRegistration($driver);
    $action->register($username, $favoriteColor, $emailAddress);
}

Fake

آخرین نوع test double ها Fake است که یک پیاده سازی کاملا مشابه از کلاسی است که سیستم مورد آزمایش ما نیاز دارد. این کلاس هرگز در کد های واقعی ما استفاده نمی شود. از Fake ها بیشتر در تست های feature و Integration استفاده می شود و معمولا در تست های Unit کاربرد چندانی ندارند. از جمله کاربرد هایی که Fake ها دارند می توان به تست cache، سرویس ایمیل، لاگ و API های خارجی استفاده شده در برنامه اشاره کرد. نکته مهمی که باید به آن توجه کنید این است که اگر برای کلاسی Fake می نویسید که اینترفیس ندارد یا final نیست این ریسک وجود دارد که Fake شما و کلاس اصلی پس از گذشت زمان با یک دیگر هماهنگ نباشند.

به عنوان مثال کلاس Post را در نظر بگیرید که هنگام نمایش post تعداد بازدید کننده های سایت را در cache افزایش می دهد سپس مقدار آن را از cache می خواند.

interface CacheInterface
{
    public function visitorsCount();

    public function increment();
}

class Cache implements CacheInterface
{
    public function visitorsCount()
    {
        return Redis::get('visitors');
    }

    public function increment()
    {
        Redis::increment('visitors');
    }
}

class Post
{
    private $cache;
    private $title = 'some title';

    public function __construct(Cache $cache)
    {
        $this->cache = $cache;
    }

    public function view()
    {
        $this->cache->increment();

        return [
            'title' => $this->title,
            'visitor_count' => $this->cache->visitorsCount()
        ];
    }
}

در کد های اصلی برنامه از کلاس cache ای استفاده می کنیم که با دیتابیس Redis در ارتباط است. اما حین تست برای ما اهمیتی ندارد که دیتابیس cache ما چیست و تنها می خواهیم اطمینان حاصل کنیم که مقدار درست از cache خوانده می شود. همچنین نمی خواهیم هنگام اجرای تست ها Redis را درگیر کنیم و داده ی غیر واقعی درون آن بریزیم. به همین علت کلاس Fake زیر را می نویسیم.

class FakeCache implements CacheInterface
{

    public $visitors;

    public function visitorsCount()
    {
        return $this->visitors;
    }

    public function increment()
    {
        $this->visitors = $this->visitors + 1;
    }
}

اکنون می توانیم تست نویسی را انجام دهیم.

class ViewPostTest extends TestCase
{
    public function setUp()
    {
        $fake = new FakeCache();
        /**
         * Configure your system to use the Fake
         * This is sample pseudo-code.
         */
        App::register(CacheInterface::class, $fake);
    }

    /** @test */
    public function visitors_count_will_be_incremented_and_returned_when_viewing_a_post()
    {
        $post = new Post();
        
        $result = $post->view();
        $this->assertEquals($result['visitors_count'],1);
        
        $newResult = $post->view();
        $this->assertEquals($result['visitors_count'],2);
    }
}

همانطور که مشاهده می کنید در متد setup تست مان تعیین کردیم که در سطح برنامه کلاس FakeCache به عنوان پیاده سازی اینترفیس CacheInterface قرار بگیرد (البته این کد کاملا فرضی است و شما برای انجام این کار باید از پکیج ها یا فریم ورک هایی که این امکان را در اختیار شما قرار می دهند استفاده کنید). بنابراین هنگام اجرای تست و صدا شدن متد view تعداد بازدید کننده ها به جای اینکه در Redis افزایش پیدا کند در آرایه ای که ما برای کلاس Fake مان تعریف کردیم زیاد می شوند. اکنون بدون ارتباط با دیتابیس Redis توانستیم برنامه را تست کنیم.

در این مطلب سعی شد انواع test double ها را با مثال های فرضی توضیح بدهیم. در بیشتر مواقع فریم ورک های تست نویسی مانند PHPUnit و پکیج های معروفی مثل Mockery این قابلیت ها را برای ما فراهم می کنند. اما درصورتی که نیازی به ایجاد آنها توسط خود ما باشد اکنون با دانش خود می توانیم آنها را بسازیم. در صورتی که ابهامی در بخش های این مطلب وجود دارد آن را در بخش نظرات با ما درمیان بگذارید.

از بهترین نوشته‌های کاربران سکان آکادمی در سکان پلاس


online-support-icon