تست فکتوری ( Test factory )
ﺯﻣﺎﻥ ﻣﻄﺎﻟﻌﻪ: 9 دقیقه

تست فکتوری ( Test factory )

در این مقاله از راکت، به بررسی نحوه مدیریت داده‌های دامنه برای تست آن‌ها می‌پردازیم. Test factory ها در لاراول یک مفهوم شناخته شده هستند؛ اگر چه در بسیاری از زمینه‌ها کمبود دارند: آن‌ها خیلی انعطاف‌پذیر نیستند و همچنین نوعی جعبه سیاه برای کاربر به حساب می‌آیند.

مثالی از factory states برای شما آورده‌ام؛ یک الگوی بسیار قدرتمند که البته در لاراول ضعیف اجرا شده است.

$factory->state(Invoice::class, 'pending', [
    'status' => PaidInvoiceState::class,
]);

اول از همه: IDE شما هیچ سرنخی از اینکه آبجکت $factory واقعاً چه نوعی است ندارد. و این به طرز شگفت‌انگیزی در فکتوری فایل‌ها وجود دارد، با وجود این هیچ autocompletion (تکمیل خودکاری) در آن وجود ندارد. یک راه‌حل سریع این است که این docblock را اضافه کنید، اگر چه که این کار دست و پاگیر و مایه زحمت است.

/** @var \Illuminate\Database\Eloquent\Factory $factory */
$factory->state(/* … */);

دوم،state ها به عنوان رشته تعریف می‌شوند، و این کار آن‌ها را به یک جعبه سیاه در هنگام استفاده از factory در تست‌ها تبدیل می‌کند.

public function test_case()
{
    $invoice = factory(Invoice::class)
        ->states(/* what states are actually available here? */)
        ->create();
}

سوم، هیچ type hinting در نتیجه‌ی یکfactory وجود ندارد، IDE شما نمی‌داند که $invoice درواقع یک Invoice model است؛ و دوباره می‌گویم: یک جعبه سیاه.

و سرانجام، با توجه به دامنه کافی، شما ممکن است به stateهای بیشتری در مجموعه تست‌های خود نیاز داشته باشید که در این صورت مدیریت آن‌ها با‌گذشت زمان دشوار می‌شود.

در این مقاله ما به یک روش دیگر از پیاده‌سازی این الگوی Factory خواهیم پرداخت تا انعطاف‌پذیری بیشتری داشته باشد و تجربه‌کاربری آن‌ها را به میزان قابل توجهی بهبود ببخشد. هدف اصلی این factory classها،‌کمک به شما در نوشتن integration tests است، بدون اینکه وقت زیادی را صرف راه‌اندازی سیستمی برای آن کنید.

توجه داشته باشید که من می‌گویم "integration tests" نه "unit tests": وقتی که ما دامنه کد خود را تست می‌کنیم، منطق اصلی تجارتمان را مورد آزمایش قرار می‌دهیم. بیشتر اوقات، این آزمایش به این معنی نیست که ما یک قطعه ایزوله شده از یک کلاس را تست خواهیم کرد، بلکه یک قانون تجاری پیچیده است که نیاز به وجود کمی ( یا مقدار زیادی) داده، در پایگاه داده دارد.

همانطور که قبلاً اشاره کردم: ما درمورد سیستم‌های بزرگ و پیچیده در این مقاله صحبت می‌کنیم؛ این مسأله مهمی‌است پس به خاطر داشته باشید. به همین خاطر تصمیم گرفتم در این مقاله این تست‌ها را integration tests بنامم، و این به منظور جلوگیری از سؤالات یا بحث‌هایی در رابطه با اینکه یونیت تست‌ها چه هستند یا نیستند بود.

basic factory

یک تست فکتوری چیزی بیش از یک کلاس ساده نیست. هیچ پکیجی مورد نیاز نیست ، هیچ اینترفیسی برای پیاده‌سازی وجود ندارد و یا حتی abstractclasseها برای گسترش  نیستند. قدرت یک factory به پیچیدگی کد نیست، بلکه باید با یک یا دو الگو به درستی اعمال شود.

در اینجا به نظر می‌رسد که چنین کلاسی ساده است:

class InvoiceFactory
{
    public static function new(): self
    {
        return new self();
    }
    
    public function create(array $extra = []): Invoice
    {
        return Invoice::create(array_merge(
            [
                'number' => 'I-1',
                'status' => PendingInvoiceState::class,
                // …
            ],
            $extra
        ));   
    }
}

بیایید درباره چند تصمیم در طراحی بحث کنیم.

اول از همه، دربارهstatic constructor new.ممکن است با دلایل نیاز ما به آن، شما گیج شوید، چنان‌که ما می‌توانیم یک متد استاتیک را به سادگی بسازیم. من این سؤال را بعداً به صورت کامل و عمیق توضیح می‌دهیم، اما الان باید بدانید که می‌خواهیم این فکتوری قبل از ایجاد یک invoice قابلیت تنظیم بالایی داشته باشد. بنابراین درباره این‌ها نگران نباشید چون به زودی برای شما واضح‌تر خواهد شد.

ثانیا، چرا new برای static constructor به کار رفته؟ پاسخ یک جواب کاربردی است:داخل context فکتوری‌ها، make و create اغلب با یک فکتوری مرتبط هستند که درواقع نتیجه‌ای را تولید می‌کنند. new به ما کمک می‌کند تا از بعضی سردرگمی‌های غیر ضروری جلوگیری کنیم.

سرانجام،متد create :به یک آرایه اختیاری از داده‌های اضافی نیاز دارد تا اطمینان حاصل شود که همیشه می‌توانیم در تست‌های خود، در لحظات آخر تغییراتی ایجاد کنیم.

 اکنون با یک مثال ساده می‌توانیم invoice (فاکتور) هایی مثل این را ایجاد کنیم:

public function test_case()
{
    $invoice = InvoiceFactory::new()->create();
}

قبل از بررسی قابلیت تنظیم، بیایید کمی به کار خود بهبود ببخشیم :شماره‌هایinvoice باید منحصر به فرد باشد ،‌بنابراین اگر  دو invoice در یک تست ایجاد کنیم، خراب می‌شود. اگر چه در بیشتر موارد نمی‌خواهیم نگران پیگیری شماره‌های invoice باشیم، بنابراین اجازه دهید فکتوری این موارد را انجام دهد:

class InvoiceFactory
{
    private static int $number = 0;

    public function create(array $extra = []): Invoice
    {
        self::$number += 1;

        return Invoice::create(array_merge(
            [
                'number' => 'I-' . self::$number,
                // …
            ],
            $extra
        ));   
    }
}

فکتوری‌ها در فکتوری‌ها

در مثال اصلی، من به شما نشان دادم که ممکن است بخواهیم یک paid invoice (فاکتور پرداختی) ایجاد کنیم. قبلا تصور می‌کردم این به معنای تغییر وضعیت فیلد در invoice model است؛ ولی خب کمی ساده لوح بودم که اینگونه فکر میکردم. برای ذخیره در پایگاه داده به یک پرداخت واقعی نیز نیاز داریم! فکتوری‌های پیش‌فرض لاراول می‌توانند این callback ها را که پس از ایجاد یک مدل شروع می‌شوند، هندل کنند. هر چند تصور کنید اگر چندین یا حتی شاید ده‌ها state برای مدیریت داشته باشید، هر کدام عوارض جانبی خاص خودشان را دارند. یک $factory→afterCreating ساده کافی نیست که بتواند همه‌ی این‌ها را به روشی سالم مدیریت کند.

بنابراین، بیایید همه چیز را برگردانیم؛ و قبل از ایجاد یک invoice واقعی، invoice factory خود را به درستی پیکربندی کنیم.

class InvoiceFactory
{
    private string $status = null;

    public function create(array $extra = []): Invoice
    {
        $invoice = Invoice::create(array_merge(
            [
                'status' => $this->status ?? PendingInvoiceState::class
            ],
            $extra
        ));
        
        if ($invoice->status->isPaid()) {
            PaymentFactory::new()->forInvoice($invoice)->create();
        }
        
        return $invoice;
    }

    public function paid(): self
    {
        $clone = clone $this;
        
        $clone->status = PaidInvoiceState::class;
        
        return $clone;
    }
}

درضمن اگر درباره clone تعجب کردید، بعداً آن را بررسی خواهیم کرد.

چیزی که ما قابل تنظیم کرده‌ایم وضعیت invoice است، دقیقاً مثل factory state‌ها در لاراول؛ اما در موردی که ما داریم این مزیت وجود دارد که IDE ما درواقع می‌داند با چه کاری روبه‌رو هستیم :

public function test_case()
{
    $invoice = InvoiceFactory::new()
        ->paid()
        ->create();
}

هنوز هم جای پیشرفت وجود دارد، آیا دقت کردید که بعد از invoice، ما create را چک کردیم؟

 
if ($invoice->status->isPaid()) {
    PaymentFactory::new()->forInvoice($invoice)->create();
}

هنوز هم می‌توان انعطاف‌پذیری بیشتری انجام داد. ما از PaymentFactory در زیر استفاده می‌کنیم، اما اگر بخواهیم کنترل دقیق‌تری درباره نحوه انجام این payment (پرداخت) داشته باشیم، چه می‌کنید؟ می‌توانید تصور کنید قوانینی در تجارت درباره paid invoices (فاکتورهای پرداخت) وجود دارد که بسته به نوع پرداخت، رفتار متفاوتی دارند.

همچنین ما می‌خواهیم از پیکربندی مستقیم در InvoiceFactory جلوگیری کنیم، چرا که خیلی زود به یک خرابکاری تبدیل می‌شود. بنابراین چگونه این کار را حل کنیم؟!

جواب اینجاست :‌ما به توسعه‌دهنده اجازه می‌دهیم که به صورت اختیاری یک PaymentFactory را به InvoiceFactory منتقل (pass )‌کند تا این فکتوری هر طور که توسعه‌دهنده می‌خواهد پیکربندی شود. در اینجا به اینگونه به نظر می‌رسد:

public function paid(PaymentFactory $paymentFactory = null): self
{
    $clone = clone $this;
    
    $clone->status = PaidInvoiceState::class;
    $clone->paymentFactory = $paymentFactory ?? PaymentFactory::new();
    
    return $clone;
}

و نحوه استفاده آن در متد create آمده است:

if ($this->paymentFactory) {
    $this->paymentFactory->forInvoice($invoice)->create();
}

با این کار، امکانات بسیاری به وجود می‌آیند. در این مثال ما در حال ساخت یک invoice (فاکتور) برای پرداخت هستیم، به‌خصوص با یکBancontact payment .

public function test_case()
{
    $invoice = InvoiceFactory::new()
        ->paid(
            PaymentFactory::new()->type(BancontactPaymentType::class)
        )
        ->create();
}

مثال دیگر: می‌خواهیم نحوه پرداخت فاکتور را هنگام پرداخت آن آزمایش کنیم، اما فقط پس از انقضای فاکتور :

public function test_case()
{
    $invoice = InvoiceFactory::new()
        ->expiresAt('2020-01-01')
        ->paid(
            PaymentFactory::new()->at('2020-01-20')
        )
        ->create();
}

فقط با چند خط کد، انعطاف بیشتری پیدا می‌کنیم.

Immutable factories

خب درباره cloning که جلوتر حرفش را زدیم چه فکر می‌کنید؟ چرا مهم است که فکتوری‌های immutable یا غیرقابل تغییر بسازیم؟ ببینید، گاهی شما نیاز به ساخت چندین مدل یا یک فکتوری یکسان دارید، اما با تفاوت‌های کوچک. به جای ساخت یک factory object جدید برای هر model ، می‌توانید از factory object اصلی مجدد استفاده کنید و فقط موارد مورد نیاز خود را تغییر دهید.

اگر چه از immutable factorie ها استفاده نمی‌کنید، اما این احتمال وجود دارد که در نهایت به داده‌هایی برسید که درواقع آن‌ها را نمی‌خواستید. مثالی درباره invoice payments یا فاکتورهای پرداخت می‌زنیم: فرض کنید ما در یک تاریخ به دو فاکتور نیاز داریم، یکی پرداخت شده و دیگری معلق مانده.

$invoiceFactory = InvoiceFactory::new()
    ->expiresAt(Carbon::make('2020-01-01'));

$invoiceA = $invoiceFactory->paid()->create();
$invoiceB = $invoiceFactory->create();

اگر متد paid ما immutable یا غیرقابل تغییر بود، به این معنی می‌بود که $invoiceB نیز یک فاکتور پرداخت شده خواهد بود! مطمئناً ما می‌توانیم روی ایجاد هر مدلی یک مدیریت همه جانبه داشته باشیم،‌ اما این، انعطاف‌پذیری این الگو را می‌گیرد. به این دلیل است که فانکشن‌های تغییر ناپذیر عالی هستند: شما می‌توانید بدون نگرانی درباره عوارض جانبی یک base factory را راه‌اندازی کنید و از آن در طول تست‌های خود استفاده مجدد کنید!

با استفاده از این دو اصل ( پیکربندی فکتوری‌ها داخل فکتوری‌ها و تغییر ناپذیر ساختن آن‌ها)، امکانات بسیار زیادی به وجود می‌آیند. مطمئنا نوشتن این فکتوری‌ها مدتی طول می‌کشد، اما در طول توسعه نیز در وقت‌مان به طور قابل توجهی صرفه‌جویی می‌شود. به نظر من آن‌ها کاملاً ارزش دارند چرا که بیشتر از هزینه‌ای که برای آن‌ها می‌کنید، دریافت خواهید کرد.

از زمان استفاده از این الگو من اصلاً و هرگز به فکتورهای داخلی لاراول نگاه نکرده ام.

یک نکته منفی که می‌توانم بگوییم این است که برای ایجاد همزمان چندین مدل، به کمی کد اضافه تر نیاز دارید. اگر بخواهید، به راحتی می‌توانید قعطه کوچکی از کد را در یک کلاس base factory مثل این اضافه کنید:

abstract class Factory
{
    // Concrete factory classes should add a return type 
    abstract public function create(array $extra = []);

    public function times(int $times, array $extra = []): Collection
    {
        return collect()
            ->times($times)
            ->map(fn() => $this->create($extra));
    }
}

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

پیشنهاد می‌کنم هر موقع که به فکتوری‌ها نیاز دارید با آن‌ها بازی کنید، به شما اطمینان می‌دهم که از آن‌ها ناامید نخواهید شد. امیدوارم از این مقاله لذت برده باشید.

منبع

چه امتیازی برای این مقاله میدهید؟

خیلی بد
بد
متوسط
خوب
عالی
4 از 1 رای

2 سال پیش
لاراول
توسعه
/@Fatemeh.shirzadfar
فاطمه شیرزادفر
برنامه نویس

تجربه کلمه‌ای هست که همه برای توصیف اشتباهاتشون ازش استفاده میکنن، و من همیشه دنبال اشتباهات جدیدم! برنامه‌نویس هستم و لینوکس‌ دوست

دیدگاه و پرسش

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

ورود یا ثبت‌نام

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

فاطمه شیرزادفر

برنامه نویس