مدیریت چرخه ی تست نویسی در لاراول

مدیریت چرخه ی تست نویسی در لاراول

اگر تا به حال مشغول تست نویسی شده باشید یا در مورد آن مطالعه کرده باشید، به احتمال زیاد به این موضوع که تست ها باید تا حد امکان به صورت مستقل از هم نوشته شوند و اجرای هرکدام از آن ها وابسته به اجرای تست دیگری نباشد، برخورد کرده اید. به طور طبیعی این مسئله به اندازه کافی مهم و کاربردی است به خصوص اگر هدف unit test باشد؛ اما مواقعی هم پیش خواهد آمد که متوجه می شویم در طول پیاده سازی چندین تست که مربوط به کارکردهای مستقلی از برنامه هستند، منابع مشترکی در تمامی این تست ها استفاده می شوند و آنجاست که به ذهنمان خطور می کند بهتر خواهد شد اگر بتوان مدیریت منابع در آن گروه از تست ها را بهینه تر کرد!

برای مثال فرض کنید شما در حال تست نویسی برای کارکردهای ایجاد (Create)، به روزرسانی (Update) و حذف (Delete) یک موجودیت در برنامه یتان باشید. طبیعی است یکی از راه هایی که می توان این تست ها را دسته بندی کرد، قرار دادن آن ها در یک کلاس مشترک است و اگر بتوان در تست مربوط به Update از موجودیتی که در تست Create ایجاد شده است، استفاده کرد، می توان روند پیاده سازی تست ها را بدون کم شدن از کارکرد اصلی آن ها بهینه تر کرد. یا برای مثال فرض کنید در طی چندین تست که محوریت مشترکی دارند، می خواهیم از یک کاربر خاص به عنوان کاربر وارد شده در طول تست ها استفاده کنیم. طبیعی است اگر بنا باشد طی هر تست این کاربر از منبعی دریافت شود یا حتی به صورت داده ی غیر اصل ایجاد شود، باعث نبود بهینگی (کدهای تکراری) و صرف زمان بیشتر طی اجرای تست ها خواهد شد.

حال برای مدیریت بهتر تست ها، می خواهیم به چند مورد از قابلیت های تست نویسی در فریمورک لاراول اشاره کنیم. بدیهی است امکانات مطرح شده، در هر زبان دیگری نیز قابلیت پیاده سازی دارند.

1 - استفاده از ویژگی depends@ روی هر تست:

/**
 * Test role creation
 * @return Role
 */
public function test_create_role()
{
	// … create a test role and assert it succeed!
	
	Return $testRole;
}
 * Test role edition
 * @depends test_create_role
 * @param Role $createdRole
 */
public function test_update_role(Role $createdRole)
{
	// … update the test role which was created before and is passed to 
	//   this test method
}

در مثال بالا با استفاده از کلمه کلیدی depends@ روی تابع test_update_role به debugger می گوییم که تست مورد نظر وابسته به اجرای تابع test_create_role است. ناگفته پیداست که اولین ایراد به ویژگی بالا، ایجاد وابستگی مستقیم بین ترتیب اجرای تست های یک کلاس است (اگرچه این مسئله یک خطا در تست نویسی نبوده و به طور خاص توصیه ی مربوط به مستقل نویسی را نقض می کند اما در جای مناسب، کاربرد خودش را دارد)! توجه شود که در صورت استفاده از این قابلیت می توان از تابع اول مقداری را Return کرده و در ورودی تابع دوم آن را دریافت کرد (در مثال بالا موجودیت Role از تابع اول به تابع دوم ارسال شده است).

2 – توابع ()setUp و ()tearDown:

توابع بالا جزو hook methods در کلاس TestCase.php از فریمورک PHPUnit است که در هسته ی لاراول در کلاس Illuminate\Foundation\Testing\TestCase.php پیاده سازی شده اند.

تابع setUp قبل از هر تست اجرا می شود. برای مثال اگر در یک کلاس، 3 تابع تست تعریف کنیم، تابع گفته شده قبل از هریک از این 3 تابع اجرا خواهد شد!

 اصلی ترین کارهای این تابع پاک سازی منابعی که قبلا استفاده شده است، از نو راه اندازی کردن محیط اپلیکیشن (bootstrapping) و آماده سازی trait های مورد استفاده در کلاس است!

/**
 * Setup the test environment.
 *
 * @return void
 */
protected function setUp(): void
{
    Facade::clearResolvedInstances();

    if (! $this->app) {
        $this->refreshApplication();
    }

    $this->setUpTraits();

    foreach ($this->afterApplicationCreatedCallbacks as $callback) {
        $callback();
    }

    Model::setEventDispatcher($this->app['events']);

    $this->setUpHasRun = true;
}

بدیهی است که در هریک از کلاس های تست در لاراول، می توان از این تابع ها بهره برد. نکته ی ضروری آن است که حتما در صورت پیاده سازی این تابع در کلاس فرزند باید پیاده سازی تابع پدر نیز فراخوانی شود. در مثالی فرض کنید ما در تمامی توابع تست می خواهیم از یک کاربر فرضی به نام Admin به عنوان کاربر وارد شده استفاده کنیم، به جای آنکه این کاربر در هر تست ایجاد شود، تنها یک بار مقدار آن ایجاد شده و به صورت اشتراکی بین تست ها مورد استفاده قرار می گیرد:

class ExampleTest extends TestCase

{
    private static $authUser;
    public function setUp(): void
    {
        parent::setUp();
        if(!self::$authUser){
             self::$authUser = User::admin()->first();
        }
    }
    /**
    * some test …
    */
    public function test_something()
    {
        $this->actingAs(self::$authUser);
       …
    }
}

 تابع tearDown کارکردی مشابه setUp دارد با این تفاوت که بعد از اجرای هر تست، اجرا خواهد شد. در ضمن در صورت پیاده سازی آن در کلاس فرزند، همچون تابع setUp به یقین باید پیاده سازی کلاس پدر نیز فراخوانی شود!

3 – توابع setUpBeforeClass و tearDownAfterClass:

این توابع نیز متعلق به فریمورک PHPUnit است. اما لاراول بهره ای از این توابع در هسته خود نبرده است. شاید یکی از دلایل آن کمتر مرسوم بودن استفاده از آن به دلیل کارآیی محدودتر آن هاست. به هر حال توسعه دهندگان بنا به نیاز خود می توانند از این توابع بهره ببرند. نکته ی دارای اهمیت static بودن این توابع است و این که این توابع تنها یک بار قبل و بعد از آنکه یک instance از کلاس تست ایجاد شده و بعد از پایان تست ها و از بین رفتن instance، فراخوانی می شوند.

 یکی از کاربردهای که برای این توابع می توان مثال زد استفاده از trait RefreshDatabase است. اگر نگاهی به کدهای RefreshDatabase بیاندازیم مشخص می شود که این trait دو وظیفه اصلی را دنبال می کند. اول آنکه قبل و بعد از اجرای هر تست، در صورتی که migration های دیتابیس اجرا نشده باشند، اقدام به اجرای migration ها می کند، دومین وظیفه ی آن ایجاد یک transaction قبل از اجرای هر تست است که در صورتی که این transaction در طول اجرای تست commit نشده باشد، پس از پایان تست به طور خودکار rollback شده و تمامی تغییرها در دیتابیس به حالت قبل از اجرای تست برگردد. به کدهای زیر که در تابع refreshTestDatabase پیاده سازی شده است دقت کنید:

/**
 * Refresh a conventional test database.
 *
 * @return void
 */
protected function refreshTestDatabase()

{
    if (! RefreshDatabaseState::$migrated) {
        $this->artisan('migrate:fresh', $this->migrateFreshUsing());
        $this->app[Kernel::class]->setArtisan(null);
        RefreshDatabaseState::$migrated = true;
    }
    $this->beginDatabaseTransaction();
}

مشخص است که تابع بالا در خطی که دستور migrate:fresh را فراخوانی می کند، از یک تابع به نام ()migrateFreshUsing نیز استفاده می کند که کد آن به شرح زیر است:

/**
 * The parameters that should be used when running "migrate:fresh".
 *
 * @return array
 */
protected function migrateFreshUsing()
{
    return [
        '--drop-views' => $this->shouldDropViews(),
        '--drop-types' => $this->shouldDropTypes(),
        '--seed' => $this->shouldSeed(),
    ];
}

نکته ی جالب این است که این تابع قابلیت اضافه کردن سه ویژگی به دستور migrate:fresh را می دهد که در مثال مورد نظر ما ویژگی seed-- مدنظر است. در تابع ()shouldSeed خواهیم دید که اگر بخواهیم از این ویژگی در دستور استفاده شود باید یک attribute به نام seed$ در کلاس تست تعریف کرده و مقدار true به آن بدهیم!

/**
 * Determine if the seed task should be run when refreshing the database.
 *
 * @return bool
 */
protected function shouldSeed()
{
    return property_exists($this, 'seed') ? $this->seed : false;
}

حال که با این دو قابلیت از RefreshDatabse آشنا شدیم تنها نکته ی باقی مانده، آن است که توجه کنیم دستور migrate:fresh به دلیل بهینه سازی، تنها یک بار در طول کلیه تست ها اجرا خواهد شد. این موضوع را از آنجایی می توان فهمید که در کدهای تابع refreshTestDatabase دیدیم که اقدام های migration در یک دستور شرطی قرار دارند و این قطعه کد همیشه اجرا نخواهد شد! مجدد به کد زیر دقت کنید:

if (! RefreshDatabaseState::$migrated) {
    $this->artisan('migrate:fresh', $this->migrateFreshUsing());
    $this->app[Kernel::class]->setArtisan(null);
    RefreshDatabaseState::$migrated = true;
}

اگر مقدار RefreshDatabaseState::$migrated برابر false باشد تابع اقدام به اجرای migration خواهد کرد. این شرط بسیار خوب و کاربردی است و باعث بهینگی در زمان اجرای تست ها خواهد شد؛ اما نکته آنجاست که ممکن است ما در کلاس A تنها به خود دستورmigrate:fresh بدون seed شدن دیتابیس نیاز داشته باشیم و در کلاس دیگری به نام B نیاز به seed دیتابیس وجود داشته باشد و این در شرایطی باشد که ترتیب اجرای کلاس های تست به طور پیش فرض بر اساس حروف الفبایی نام کلاس ها می باشد و باعث می شود کلاس A زودتر اجرا شده و کلاس B که نیاز به seed دارد پس از کلاس اول اجرا شود. چه اتفاقی میافتد؟ در کلاس B چون ویژگی RefreshDatabaseState::$migrated برابر true شده است دیگر دستورهای migration اجرا نخواهند شد و در نتیجه هیچگونه seed در کلاس B اتفاق نمی افتد.

کافی است در کلاس B تابع ()setUpBeforeClass را پیاده سازی کرده و در آن مقدار RefreshDatabaseState::$migrated را برابر false قرار دهیم. در واقع با این کار به طور روشن می گوییم که قبل از اجرای این کلاس در هر شرایطی نیاز است تا migration دیتابیس همراه با seed اتفاق افتاده و دیگر نیازی نیست نگران ترتیب اجرا شدن کلاس های تست باشیم.

public static function setUpBeforeClass(): void

{
    RefreshDatabaseState::$migrated = false; // reset migrations anyway!
}

شاید با خودتان بگویید مثال اخیر خیلی خاص بوده و اصلا عمومیت چندانی ندارد. به احتمال زیاد همین طور است و حق با شماست. هدف از بیان مثال اخیر این بود که یکی از کاربرد های توابع ذکر شده را ببینیم و احتمالا دلیل اصلی آن که استفاده از این توابع کمتر مرسوم است این باشد که کارکرد محدودتری دارد. موضوع دیگر در مثال اخیر آشنایی دقیق تر با جزییات trait RefrehDatabase و کارکردهای اصلی آن بوده است.

نتیجه گیری:

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

در نهایت منابع زیر جهت مطالعه بیشتر در مورد ویژگی های مرتبط توصیه می شود:

https://medium.com/@SlyFireFox/laravel-tips-making-your-own-trait-hooks-for-tests-770f0aeda3c9

https://itnext.io/simplifying-laravel-tests-by-using-factory-setup-classes-8bd84029ff6b

 

منابع استفاده شده:

https://dev.to/daniel_werner/under-the-hood-how-refreshdatabase-works-in-laravel-tests-2728

https://phpunit.de/manual/6.5/en/fixtures.html

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


online-support-icon