مقاله

Unit Test و Mock در Laravel — تست Service بدون دیتابیس

Unit Test و Mock در Laravel — تست Service بدون دیتابیس

Mockery، fake dependencies، تست PostService و Action — سریع و focused بدون HTTP.

وقتی منطق کسب‌وکار به repository، درگاه پرداخت یا HTTP وابسته است، Feature test کامل کند و پرهزینه می‌شود. Unit test همان منطق را با جایگزین کردن وابستگی‌ها ایزوله می‌کند تا تغییر قانون تخفیف فقط یک تست کوچک را بشکند، نه کل suite. این مقاله روی test double و Mockery تمرکز دارد؛ پوشش coverage و CI در راهنمای coverage است و HTTP end-to-end در Feature test.

Unit test چیست و چه نیست؟

Unit test یک واحد منطق را با ورودی و خروجی مشخص می‌سنجد — معمولاً بدون مرورگر، بدون migration سنگین و بدون شبکه واقعی. مثال خوب: محاسبه قیمت، ساخت slug، تصمیم «آیا سفارش قابل تأیید است؟».

Unit test نیست: تست route و Blade، جریان کامل login تا checkout، یا اطمینان از اینکه migration روی MySQL درست است. آن‌ها Feature/Integration هستند.

  • مناسب unit: CalculateReadingTimeAction، PriceCalculator، Service با repository تزریق‌شده
  • مناسب Feature: فرم Contoller، middleware، پاسخ JSON واقعی API

Mock، Stub، Fake، Spy — تفاوت کاربردی

نوعنقش
Stubپاسخ از پیش‌تعیین‌شده می‌دهد؛ معمولاً تعاملات را سخت assert نمی‌کنید
Mockهم پاسخ می‌دهد هم انتظار فراخوانی (مثلاً shouldReceive(...)->once())
Fakeپیاده‌سازی سبک و واقعی‌نما (مثل Http::fake یا صف in-memory)
Spyاجرای واقعی + رصد فراخوانی‌ها بعد از عمل

قانون ساده: اگر فقط به خروجی کلاس تحت تست اهمیت می‌دهید، stub/fake کافی است. اگر قرارداد همکاری (مثلاً دقیقاً یک بار charge) بخشی از رفتار است، mock مناسب است. جزئیات پیشرفته‌تر Mockery در Mockery عمیق.

چرا Dependency Injection مهم است؟

اگر Service داخل سازنده new StripeGateway() بسازد، تست مجبور است شبکه واقعی بزند یا کلاس را باز کند. با تزریق interface یا کلاس از constructor، در تست یک double می‌دهید و در production پیاده‌سازی واقعی از container resolve می‌شود.

class ChargeOrderAction
{
    public function __construct(
        private PaymentGateway $payments,
    ) {}

    public function execute(Order $order): string
    {
        $result = $this->payments->charge($order->total, $order->currency);

        return $result['id'];
    }
}

مثال pure بدون double

اولین و بهترین unit همان منطق بدون وابستگی است — نیازی به Mockery نیست. مثال با PHPUnit ۱۱ (همان خانواده‌ای که در بسیاری پروژه‌های Laravel ۱۲ استفاده می‌شود):

// tests/Unit/CalculateReadingTimeActionTest.php
use App\Actions\CalculateReadingTimeAction;
use PHPUnit\Framework\TestCase;

class CalculateReadingTimeActionTest extends TestCase
{
    public function test_calculates_minutes_from_word_count(): void
    {
        $action = new CalculateReadingTimeAction;
        $html = str_repeat('word ', 400);

        $this->assertSame(2, $action->execute($html));
    }
}

Mock با Mockery برای collaborator

use Mockery;
use PHPUnit\Framework\TestCase;

class PostServiceTest extends TestCase
{
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_creates_post_through_repository(): void
    {
        $repo = Mockery::mock(PostRepository::class);
        $repo->shouldReceive('create')
            ->once()
            ->with(Mockery::on(fn (array $data) => $data['title'] === 'Test'))
            ->andReturn(new Post(['title' => 'Test']));

        $service = new PostService($repo);
        $post = $service->create(['title' => 'Test']);

        $this->assertSame('Test', $post->title);
    }
}

اگر از Tests\TestCase لاراول استفاده می‌کنید، معمولاً Mockery::close() از قبل در tearDown پایه هست؛ در Unit سبک PHPUnit خودتان ببندید.

Mock از container لاراول

$this->mock(PaymentGateway::class, function ($mock) {
    $mock->shouldReceive('charge')->once()->andReturn(['id' => 'pay_123']);
});

$action = $this->app->make(ChargeOrderAction::class);

معادل صریح: $this->instance(PaymentGateway::class, $mock). مناسب وقتی Action/Service از container resolve می‌شود نه با new.

Fake برای HTTP، صف و Event

Http::fake([
    'api.example.com/*' => Http::response(['ok' => true], 200),
]);

app(BillingClient::class)->charge(1990);

Http::assertSent(fn ($request) =>
    $request->url() === 'https://api.example.com/charge'
);

برای Event و Notification هم fake لاراول معمولاً خواناتر از mock خام است — Event و Listener. Dataset و expectationهای سفارشی جای دیگری پوشش داده می‌شود: datasets و custom expectations.

Partial mock — با احتیاط

Partial mock یک متد را جعل می‌کند و بقیه را واقعی نگه می‌دارد. مفید برای جدا کردن یک وابستگی داخلی است، اما سریع به تست implementation جزئیات می‌لغزد. اگر زیاد به آن نیاز دارید، احتمالاً کلاس باید شکسته شود.

کی Feature بهتر از mock است؟

  • رفتار به middleware، validation یا serialization واقعی وابسته است
  • قرارداد JSON برای کلاینت بیرونی است و تغییر ساختار مهم‌تر از فراخوانی داخلی است
  • over-mock کردن باعث می‌شود تست سبز بماند در حالی که wiring واقعی شکسته است

یک Feature کوتاه + چند unit متمرکز معمولاً بهتر از ده‌ها mock شکننده است.

شکست‌های رایج و دیباگ

  • Mockery\Exception\InvalidCountException — انتظار once/twice با واقعیت نمی‌خواند؛ یا مسیر کد عوض شده یا assert زیادی سخت است
  • فراموش Mockery::close() در Unit خالص — تست بعدی آلوده می‌شود
  • mock کردن Eloquent model به‌جای repository — brittle و سخت
  • assert روی ترتیب فراخوانی‌های بی‌اهمیت — با تغییر refactor بی‌دلیل می‌شکند

جمع‌بندی

Unit test برای منطق وابسته به collaborator یعنی: وابستگی را تزریق کنید، double مناسب انتخاب کنید، رفتار را بسنجید نه جزئیات خصوصی کلاس. برای pipeline پوشش و CI به coverage و CI بروید؛ برای سبک نوشتار تست‌ها مقدمه Pest را ببینید — در این پروژه هسته مثال‌ها با PHPUnit و Mockery هم‌خوان است.

مشاوره تست و کیفیت کد

اشتراک‌گذاری