HardПрактика9 min

Mocking и Fakes

Мокирование фасадов (Bus::fake, Event::fake, Notification::fake, Queue::fake, Storage::fake, Mail::fake, Http::fake), Mockery, partialMock, spying

Laravel предоставляет встроенные "fakes" для подмены реальных сервисов в тестах. Fakes перехватывают вызовы и позволяют делать assertions без реального выполнения.

Bus::fake (Job Dispatching)

<?php

use App\Jobs\ProcessOrder;
use App\Jobs\SendInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\Bus;

public function test_order_dispatches_job(): void
{
    Bus::fake();

    $order = Order::factory()->create();

    $this->postJson("/api/orders/{$order->id}/process");

    // Assert job was dispatched
    Bus::assertDispatched(ProcessOrder::class);

    // Assert job was dispatched with specific data
    Bus::assertDispatched(ProcessOrder::class, function (ProcessOrder $job) use ($order) {
        return $job->order->id === $order->id;
    });

    // Assert dispatched count
    Bus::assertDispatched(ProcessOrder::class, 1); // Exactly once

    // Assert NOT dispatched
    Bus::assertNotDispatched(SendInvoice::class);

    // Assert nothing dispatched
    // Bus::assertNothingDispatched();
}

Fake только определённые jobs

<?php

// Only fake specific jobs (others execute normally)
Bus::fake([ProcessOrder::class]);

// Everything except specified jobs are faked
Bus::fake()->except([ProcessOrder::class]);

Job Chains и Batches

<?php

use Illuminate\Support\Facades\Bus;

public function test_job_chain(): void
{
    Bus::fake();

    $this->postJson('/api/import', ['file' => 'data.csv']);

    // Assert chain was dispatched
    Bus::assertChained([
        ValidateImport::class,
        ProcessImport::class,
        NotifyCompletion::class,
    ]);
}

public function test_job_batch(): void
{
    Bus::fake();

    $this->postJson('/api/users/send-newsletter');

    Bus::assertBatched(function (\Illuminate\Bus\PendingBatch $batch) {
        return $batch->name === 'newsletter'
            && $batch->jobs->count() === 100;
    });
}

Event::fake

<?php

use App\Events\OrderPlaced;
use App\Events\PaymentProcessed;
use Illuminate\Support\Facades\Event;

public function test_order_fires_event(): void
{
    Event::fake();

    $order = Order::factory()->create();
    $this->postJson("/api/orders/{$order->id}/place");

    // Assert event was dispatched
    Event::assertDispatched(OrderPlaced::class);

    // Assert with callback
    Event::assertDispatched(OrderPlaced::class, function (OrderPlaced $event) use ($order) {
        return $event->order->id === $order->id;
    });

    // Assert count
    Event::assertDispatched(OrderPlaced::class, 1);

    // Assert NOT dispatched
    Event::assertNotDispatched(PaymentProcessed::class);

    // Assert listener is listening
    Event::assertListening(
        OrderPlaced::class,
        SendOrderConfirmation::class
    );
}

Fake определённых событий

<?php

// Only fake specific events
Event::fake([OrderPlaced::class]);
// Other events (like model events) fire normally!

// Fake all except specific events
Event::fake()->except([
    \Illuminate\Auth\Events\Registered::class,
]);

Подвох на экзамене: Event::fake() без аргументов подавляет ВСЕ события, включая model events (creating, created, updating и т.д.). Это может привести к тому, что observers и model events не сработают. Для фейкинга только конкретных событий передайте массив.

fakeFor -- временный fake

<?php

// Fake events only for specific closure
$order = Event::fakeFor(function () {
    // Events are faked inside this closure
    $order = Order::factory()->create();
    $order->place();

    Event::assertDispatched(OrderPlaced::class);

    return $order;
});

// Events are real again here
$order->update(['status' => 'shipped']); // This fires real events

Notification::fake

<?php

use App\Models\User;
use App\Notifications\OrderShipped;
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\Notification;

public function test_notification_sent(): void
{
    Notification::fake();

    $user = User::factory()->create();
    $order = Order::factory()->for($user)->create();

    $this->postJson("/api/orders/{$order->id}/ship");

    // Assert notification was sent to specific user
    Notification::assertSentTo($user, OrderShipped::class);

    // Assert with callback
    Notification::assertSentTo(
        $user,
        OrderShipped::class,
        function (OrderShipped $notification, array $channels) use ($order) {
            return $notification->order->id === $order->id
                && in_array('mail', $channels);
        }
    );

    // Assert notification was sent to multiple users
    $users = User::factory(3)->create();
    Notification::assertSentTo($users, WelcomeNotification::class);

    // Assert NOT sent
    Notification::assertNotSentTo($user, WelcomeNotification::class);

    // Assert nothing was sent
    // Notification::assertNothingSent();

    // Assert count
    Notification::assertCount(1);
}

On-Demand Notifications

<?php

public function test_on_demand_notification(): void
{
    Notification::fake();

    // On-demand notification (no User model)
    \Illuminate\Support\Facades\Notification::route('mail', '[email protected]')
        ->notify(new OrderShipped($order));

    Notification::assertSentOnDemand(
        OrderShipped::class,
        function (OrderShipped $notification, array $channels, $notifiable) {
            return $notifiable->routes['mail'] === '[email protected]';
        }
    );
}

Queue::fake

<?php

use App\Jobs\ProcessPayment;
use App\Jobs\SendReceipt;
use Illuminate\Support\Facades\Queue;

public function test_jobs_pushed_to_queue(): void
{
    Queue::fake();

    $this->postJson('/api/payments', [
        'amount' => 100,
        'method' => 'card',
    ]);

    // Assert job was pushed
    Queue::assertPushed(ProcessPayment::class);

    // Assert pushed to specific queue
    Queue::assertPushedOn('payments', ProcessPayment::class);

    // Assert with callback
    Queue::assertPushed(ProcessPayment::class, function (ProcessPayment $job) {
        return $job->amount === 100;
    });

    // Assert NOT pushed
    Queue::assertNotPushed(SendReceipt::class);

    // Assert count
    Queue::assertCount(1);

    // Assert nothing pushed
    // Queue::assertNothingPushed();
}

Подвох на экзамена: Bus::fake() и Queue::fake() -- это разные вещи. Bus::fake() перехватывает dispatch(). Queue::fake() перехватывает Queue::push() и Queue::later(). Для стандартных dispatchable jobs используйте Bus::fake().

Storage::fake

<?php

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

public function test_file_upload(): void
{
    // Create fake disk (in-memory filesystem)
    Storage::fake('public');

    $file = UploadedFile::fake()->image('avatar.jpg', 200, 200);

    $this->postJson('/api/avatar', [
        'avatar' => $file,
    ]);

    // Assert file exists on fake disk
    Storage::disk('public')->assertExists('avatars/avatar.jpg');

    // Assert file does NOT exist
    Storage::disk('public')->assertMissing('avatars/old.jpg');

    // Assert directory exists
    Storage::disk('public')->assertDirectoryExists('avatars');

    // Assert file count
    $files = Storage::disk('public')->files('avatars');
    $this->assertCount(1, $files);
}

Storage assertions

<?php

Storage::fake('s3');

// After uploading...
Storage::disk('s3')->assertExists('path/to/file.pdf');
Storage::disk('s3')->assertMissing('path/to/nonexistent.pdf');

// Check file content
$content = Storage::disk('s3')->get('path/to/file.txt');
$this->assertEquals('expected content', $content);

// Check file size
$size = Storage::disk('s3')->size('path/to/file.pdf');
$this->assertGreaterThan(0, $size);

Mail::fake

<?php

use App\Mail\OrderConfirmation;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;

public function test_order_email_sent(): void
{
    Mail::fake();

    $user = User::factory()->create();
    $order = Order::factory()->for($user)->create();

    $this->postJson("/api/orders/{$order->id}/confirm");

    // Assert mailable was sent
    Mail::assertSent(OrderConfirmation::class);

    // Assert sent to specific address
    Mail::assertSent(OrderConfirmation::class, function (OrderConfirmation $mail) use ($user) {
        return $mail->hasTo($user->email)
            && $mail->order->id === $user->id;
    });

    // Assert NOT sent
    Mail::assertNotSent(WelcomeMail::class);

    // Assert queued (if using ShouldQueue)
    Mail::assertQueued(OrderConfirmation::class);
    Mail::assertNotQueued(WelcomeMail::class);

    // Assert nothing sent
    // Mail::assertNothingSent();
    // Mail::assertNothingQueued();

    // Assert count
    Mail::assertSentCount(1);
}

Mail assertions на содержимое

<?php

Mail::fake();

// ... trigger mail

Mail::assertSent(OrderConfirmation::class, function (OrderConfirmation $mail) {
    // Check recipients
    $mail->hasTo('[email protected]');
    $mail->hasCc('[email protected]');
    $mail->hasBcc('[email protected]');
    $mail->hasFrom('[email protected]');
    $mail->hasReplyTo('[email protected]');

    // Check subject
    $mail->hasSubject('Order Confirmation');

    // Check attachments
    $mail->hasAttachment(\Illuminate\Mail\Mailables\Attachment::fromPath('/path/to/invoice.pdf'));

    // Check metadata/tags
    $mail->hasMetadata('order_id', '123');
    $mail->hasTag('order');

    return true;
});

Http::fake

<?php

use Illuminate\Support\Facades\Http;

public function test_external_api_call(): void
{
    // Fake ALL HTTP requests
    Http::fake();

    // Fake specific URLs
    Http::fake([
        'api.github.com/*' => Http::response(['login' => 'octocat'], 200),
        'api.stripe.com/charges' => Http::response(['id' => 'ch_123'], 201),
        '*' => Http::response('Not Found', 404), // Fallback for all other URLs
    ]);

    $this->postJson('/api/charge', ['amount' => 1000]);

    // Assert request was made
    Http::assertSent(function (\Illuminate\Http\Client\Request $request) {
        return $request->url() === 'https://api.stripe.com/charges'
            && $request->hasHeader('Authorization')
            && $request['amount'] === 1000;
    });

    // Assert NOT sent to specific URL
    Http::assertNotSent(function (\Illuminate\Http\Client\Request $request) {
        return str_contains($request->url(), 'api.github.com');
    });

    // Assert exact count of requests
    Http::assertSentCount(1);

    // Assert nothing sent
    // Http::assertNothingSent();
}

Sequence responses

<?php

Http::fake([
    'api.example.com/*' => Http::sequence()
        ->push(['status' => 'pending'], 200)  // First request
        ->push(['status' => 'complete'], 200)  // Second request
        ->pushStatus(429)                       // Third request: rate limited
        ->whenEmpty(Http::response('', 500)),   // All subsequent requests
]);

// First call: 200 with status=pending
// Second call: 200 with status=complete
// Third call: 429
// Fourth+ calls: 500

Prevent stray requests

<?php

// Throw exception if any un-faked request is made
Http::preventStrayRequests();

Http::fake([
    'api.known.com/*' => Http::response('OK'),
]);

// This will throw an exception:
// Http::get('https://api.unknown.com/data');

Mockery

Мокирование сервисов

<?php

use App\Services\PaymentGateway;
use Mockery\MockInterface;

public function test_payment_processing(): void
{
    // Create a mock
    $mock = $this->mock(PaymentGateway::class, function (MockInterface $mock) {
        $mock->shouldReceive('charge')
            ->once()
            ->with(100, 'USD')
            ->andReturn(['id' => 'txn_123', 'status' => 'success']);
    });

    // The mock is automatically bound in the container
    $response = $this->postJson('/api/payments', [
        'amount' => 100,
        'currency' => 'USD',
    ]);

    $response->assertOk()
        ->assertJsonPath('data.transaction_id', 'txn_123');
}

Partial Mock

<?php

use App\Services\OrderService;

public function test_order_with_partial_mock(): void
{
    // Partial mock: only mock specific methods
    $this->partialMock(OrderService::class, function (MockInterface $mock) {
        $mock->shouldReceive('calculateTax')
            ->andReturn(10.00);

        // All other methods work normally!
    });

    $response = $this->postJson('/api/orders', [
        'items' => [['product_id' => 1, 'qty' => 2]],
    ]);

    $response->assertOk();
}

Подвох на экзамене: $this->mock() заменяет ВСЕ методы сервиса. Немокированные методы возвращают null. $this->partialMock() мокирует только указанные методы, остальные работают нормально.

Spy

<?php

public function test_with_spy(): void
{
    // Spy: records calls but executes real methods
    $spy = $this->spy(PaymentGateway::class);

    $this->postJson('/api/payments', [
        'amount' => 100,
        'currency' => 'USD',
    ]);

    // Verify after the fact (spy pattern)
    $spy->shouldHaveReceived('charge')
        ->once()
        ->with(100, 'USD');

    $spy->shouldNotHaveReceived('refund');
}

Подвох на экзамене: Spy отличается от Mock тем, что spy записывает вызовы и позволяет проверять их ПОСЛЕ выполнения. Mock требует определения ожиданий ПЕРЕД выполнением.

Mockery expectations

<?php

$mock = \Mockery::mock(PaymentGateway::class);

// Call expectations
$mock->shouldReceive('charge')->once();           // Exactly 1 time
$mock->shouldReceive('charge')->twice();           // Exactly 2 times
$mock->shouldReceive('charge')->times(3);          // Exactly 3 times
$mock->shouldReceive('charge')->atLeast()->once(); // At least 1 time
$mock->shouldReceive('charge')->atMost()->twice(); // At most 2 times
$mock->shouldReceive('charge')->never();           // Never called

// Argument matching
$mock->shouldReceive('charge')->with(100);                    // Exact value
$mock->shouldReceive('charge')->with(\Mockery::any());        // Any value
$mock->shouldReceive('charge')->with(\Mockery::type('int'));  // Type check
$mock->shouldReceive('charge')->withArgs(fn ($amount) => $amount > 0); // Callback

// Return values
$mock->shouldReceive('charge')->andReturn('result');
$mock->shouldReceive('charge')->andReturnNull();
$mock->shouldReceive('charge')->andReturnTrue();
$mock->shouldReceive('charge')->andReturnFalse();
$mock->shouldReceive('charge')->andThrow(new \Exception('Failed'));
$mock->shouldReceive('charge')->andReturnUsing(fn ($amount) => $amount * 1.1);

// Sequential returns
$mock->shouldReceive('getStatus')
    ->andReturn('pending', 'processing', 'complete'); // 1st, 2nd, 3rd call

Mocking Facades directly

<?php

use Illuminate\Support\Facades\Cache;

public function test_caching(): void
{
    Cache::shouldReceive('get')
        ->once()
        ->with('users')
        ->andReturn(collect([/* users */]));

    Cache::shouldReceive('put')
        ->once()
        ->with('users', \Mockery::type('object'), \Mockery::any());

    // Execute code that uses Cache...
}

Pest-стиль мокирования

<?php

use App\Services\PaymentGateway;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Bus;

test('order processing dispatches job', function () {
    Bus::fake();

    $order = Order::factory()->create();

    $this->postJson("/api/orders/{$order->id}/process");

    Bus::assertDispatched(ProcessOrder::class, fn ($job) =>
        $job->order->id === $order->id
    );
});

test('payment gateway is called correctly', function () {
    $this->mock(PaymentGateway::class, function ($mock) {
        $mock->shouldReceive('charge')
            ->once()
            ->with(100, 'USD')
            ->andReturn(['status' => 'success']);
    });

    $this->postJson('/api/charge', ['amount' => 100, 'currency' => 'USD'])
        ->assertOk();
});

test('events are fired on registration', function () {
    Event::fake([\App\Events\UserRegistered::class]);

    $this->postJson('/api/register', [
        'name' => 'John',
        'email' => '[email protected]',
        'password' => 'SecurePass123!',
    ])->assertCreated();

    Event::assertDispatched(\App\Events\UserRegistered::class);
});

Проверь себя

Что делает Http::preventStrayRequests()?

Чем `$this->mock()` отличается от `$this->partialMock()`?

В чём разница между Bus::fake() и Queue::fake()?

Чем Spy отличается от Mock в контексте тестирования?

Что произойдёт, если вызвать Event::fake() без аргументов?