Mocking

Com fer mocking de serveis a Laravel: fakes integrats per a Mail, Queue, Bus, Notification, Storage i Event, mocks personalitzats i spies.

Què és el mocking?#

El mocking és una tècnica de testing que consisteix a substituir components reals per versions simulades (mocks) que imiten el seu comportament sense executar la lògica real. Quan testem una funcionalitat que envia un correu electrònic, no volem enviar un correu real: volem verificar que el codi intenta enviar-lo amb els paràmetres correctes. Quan testem una funcionalitat que processa un pagament, no volem fer un càrrec real a una targeta de crèdit: volem verificar que la petició al gateway de pagament és correcta.

Laravel porta el mocking a un altre nivell amb els seus fakes integrats. En lloc d'haver de crear mocks manualment amb una llibreria com Mockery (que Laravel també suporta), la majoria de serveis de Laravel tenen un mètode fake() que substitueix el servei real per una versió simulada. Aquesta versió simulada registra totes les interaccions i proporciona assertions específiques per verificar-les.

La filosofia és senzilla: Mail::fake() substitueix el servei de correu real perquè no s'enviï cap correu, però registra tots els intents d'enviament perquè puguis verificar que s'han intentat enviar els correus correctes als destinataris correctes. El mateix patró s'aplica a Queue, Notification, Storage, Event i Bus.

Mail Fake#

El fake de Mail és un dels més utilitzats. Substitueix el servei de correu perquè cap correu s'enviï realment durant el test, però registra tots els Mailables que s'han intentat enviar:

use App\Mail\WelcomeEmail;
use App\Mail\VerificationEmail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
 
test('welcome email is sent on registration', function () {
    Mail::fake();
 
    $this->post('/register', [
        'name' => 'Joan Pere',
        'email' => 'joan@example.com',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);
 
    // Verificar que s'ha enviat el correu de benvinguda
    Mail::assertSent(WelcomeEmail::class);
 
    // Verificar el destinatari
    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('joan@example.com');
    });
 
    // Verificar que s'ha enviat exactament 1 vegada
    Mail::assertSent(WelcomeEmail::class, 1);
});
 
test('verification email is sent but welcome is not for existing users', function () {
    Mail::fake();
 
    $user = User::factory()->create();
 
    // Acció que envia verificació però no benvinguda
    $user->sendEmailVerificationNotification();
 
    Mail::assertNotSent(WelcomeEmail::class);
});
 
test('no emails are sent when validation fails', function () {
    Mail::fake();
 
    $this->post('/register', [
        'name' => '',
        'email' => 'invalid-email',
    ]);
 
    Mail::assertNothingSent();
});

Verificacions avançades de Mail#

Pots verificar no només que un correu s'ha enviat, sinó també el seu contingut, destinataris, CC, BCC i adjunts:

test('order confirmation includes correct details', function () {
    Mail::fake();
 
    $user = User::factory()->create();
    $order = Order::factory()->create([
        'user_id' => $user->id,
        'total' => 150.00,
    ]);
 
    // Acció que envia el correu de confirmació
    $order->sendConfirmation();
 
    Mail::assertSent(OrderConfirmation::class, function ($mail) use ($user, $order) {
        return $mail->hasTo($user->email)
            && $mail->hasCc('comptabilitat@example.com')
            && $mail->order->id === $order->id;
    });
});
 
test('invoice email has PDF attachment', function () {
    Mail::fake();
 
    $invoice = Invoice::factory()->create();
    $invoice->sendByEmail();
 
    Mail::assertSent(InvoiceMail::class, function ($mail) {
        return $mail->hasAttachment('factura.pdf');
    });
});

Queue Fake#

El fake de Queue impedeix que els jobs s'enviïn realment a la cua i registra tots els intents. Això és especialment útil per verificar que certes accions disparen jobs sense haver d'esperar que el worker els processi:

use App\Jobs\ProcessPodcast;
use App\Jobs\GenerateThumbnail;
use App\Jobs\NotifySubscribers;
use Illuminate\Support\Facades\Queue;
 
test('uploading a podcast dispatches processing job', function () {
    Queue::fake();
 
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->post('/podcasts', [
            'title' => 'Episodi 1',
            'audio' => UploadedFile::fake()->create('episodi.mp3', 50000),
        ]);
 
    // Verificar que el job s'ha enviat a la cua
    Queue::assertPushed(ProcessPodcast::class);
 
    // Verificar les propietats del job
    Queue::assertPushed(ProcessPodcast::class, function ($job) {
        return $job->podcast->title === 'Episodi 1';
    });
});
 
test('publishing article dispatches multiple jobs', function () {
    Queue::fake();
 
    $article = Article::factory()->create();
    $article->publish();
 
    Queue::assertPushed(GenerateThumbnail::class);
    Queue::assertPushed(NotifySubscribers::class);
});
 
test('draft articles do not dispatch any jobs', function () {
    Queue::fake();
 
    Article::factory()->draft()->create();
 
    Queue::assertNothingPushed();
});

Verificar la cua i la cadena de jobs#

test('job is dispatched to the correct queue', function () {
    Queue::fake();
 
    $order = Order::factory()->create();
    $order->process();
 
    Queue::assertPushedOn('payments', ProcessPayment::class);
});
 
test('jobs are chained in correct order', function () {
    Queue::fake();
 
    $video = Video::factory()->create();
    $video->process();
 
    Queue::assertChained([
        ExtractAudio::class,
        GenerateSubtitles::class,
        PublishVideo::class,
    ]);
});

Notification Fake#

El fake de Notification impedeix que les notificacions s'enviïn realment i registra a qui s'han enviat i per quin canal:

use App\Notifications\ArticlePublished;
use App\Notifications\NewComment;
use App\Notifications\InvoiceOverdue;
use Illuminate\Support\Facades\Notification;
 
test('followers are notified when article is published', function () {
    Notification::fake();
 
    $author = User::factory()->create();
    $follower1 = User::factory()->create();
    $follower2 = User::factory()->create();
 
    $author->followers()->attach([$follower1->id, $follower2->id]);
 
    $article = Article::factory()->create(['user_id' => $author->id]);
    $article->publish();
 
    // Verificar que els seguidors han rebut la notificació
    Notification::assertSentTo(
        [$follower1, $follower2],
        ArticlePublished::class
    );
 
    // Verificar que l'autor NO ha rebut la notificació
    Notification::assertNotSentTo($author, ArticlePublished::class);
});
 
test('comment author is notified', function () {
    Notification::fake();
 
    $articleAuthor = User::factory()->create();
    $article = Article::factory()->create(['user_id' => $articleAuthor->id]);
    $commenter = User::factory()->create();
 
    $this->actingAs($commenter)
        ->post("/articles/{$article->id}/comments", [
            'body' => 'Molt bon article!',
        ]);
 
    Notification::assertSentTo($articleAuthor, NewComment::class, function ($notification) use ($commenter) {
        return $notification->comment->user_id === $commenter->id;
    });
});
 
test('notification count is correct', function () {
    Notification::fake();
 
    $users = User::factory()->count(5)->create();
    $invoice = Invoice::factory()->overdue()->create();
 
    // Enviar notificació a tots els admins
    Notification::send($users, new InvoiceOverdue($invoice));
 
    Notification::assertSentTo($users, InvoiceOverdue::class);
    Notification::assertCount(5);
});

Storage Fake#

El fake de Storage crea un sistema de fitxers virtual en memòria que es comporta exactament com un disc real però sense escriure res al disc. Això fa que els tests de pujada de fitxers siguin ràpids i nets:

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
 
test('user can upload avatar', function () {
    Storage::fake('public');
 
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->post('/profile/avatar', [
            'avatar' => UploadedFile::fake()->image('avatar.jpg', 300, 300),
        ])
        ->assertRedirect('/profile');
 
    // Verificar que el fitxer existeix
    Storage::disk('public')->assertExists("avatars/{$user->id}.jpg");
});
 
test('old avatar is deleted when uploading new one', function () {
    Storage::fake('public');
 
    $user = User::factory()->create();
 
    // Pujar primer avatar
    Storage::disk('public')->put("avatars/{$user->id}.jpg", 'old-content');
 
    // Pujar nou avatar
    $this->actingAs($user)
        ->post('/profile/avatar', [
            'avatar' => UploadedFile::fake()->image('new-avatar.jpg'),
        ]);
 
    // Verificar que el nou fitxer existeix (amb el contingut nou)
    Storage::disk('public')->assertExists("avatars/{$user->id}.jpg");
});
 
test('uploaded files have correct directory structure', function () {
    Storage::fake('public');
 
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->post('/documents', [
            'document' => UploadedFile::fake()->create('report.pdf', 1000),
        ]);
 
    // Verificar l'estructura de directoris
    Storage::disk('public')->assertExists("documents/{$user->id}/report.pdf");
 
    // Verificar que no hi ha fitxers en altres llocs
    Storage::disk('public')->assertMissing('documents/unauthorized.pdf');
});
 
// Verificar el nombre de fitxers
test('batch upload creates correct number of files', function () {
    Storage::fake('public');
 
    $user = User::factory()->create();
    $files = [];
    for ($i = 0; $i < 3; $i++) {
        $files[] = UploadedFile::fake()->image("photo_{$i}.jpg");
    }
 
    $this->actingAs($user)
        ->post('/gallery/upload', ['photos' => $files]);
 
    // Verificar cada fitxer individualment
    foreach ($files as $i => $file) {
        Storage::disk('public')->assertExists("gallery/{$user->id}/photo_{$i}.jpg");
    }
});

Event Fake#

El fake d'Event impedeix que els events disparin els seus listeners. Això és útil quan vols testejar que un event es dispara sense executar els efectes secundaris dels listeners:

use App\Events\OrderPlaced;
use App\Events\OrderShipped;
use App\Events\PaymentReceived;
use Illuminate\Support\Facades\Event;
 
test('placing an order fires OrderPlaced event', function () {
    Event::fake();
 
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->post('/orders', [
            'product_id' => 1,
            'quantity' => 2,
        ]);
 
    Event::assertDispatched(OrderPlaced::class, function ($event) {
        return $event->order->quantity === 2;
    });
});
 
test('order events fire in correct sequence', function () {
    Event::fake();
 
    $order = Order::factory()->create();
    $order->process();
    $order->ship();
 
    Event::assertDispatched(PaymentReceived::class);
    Event::assertDispatched(OrderShipped::class);
});
 
// Fake selectiu: només intercepta events específics
test('order processing works but listeners are faked', function () {
    Event::fake([OrderShipped::class]);
 
    $order = Order::factory()->create();
 
    // OrderPlaced s'executa normalment (amb els seus listeners)
    // OrderShipped es registra però els listeners no s'executen
    $order->process();
    $order->ship();
 
    Event::assertDispatched(OrderShipped::class);
});

El fake selectiu (Event::fake([OrderShipped::class])) és molt potent: permet que la majoria d'events funcionin normalment (amb els seus listeners) i només intercepta els events específics que vols verificar. Això és útil quan el flux depèn dels efectes secundaris d'alguns events però vols evitar els d'altres.

Bus Fake#

El fake de Bus és similar al de Queue però per a comandes (commands) despatxades a través del Bus. En la pràctica, amb Laravel modern, sovint s'utilitza Queue::fake() per als jobs i Bus::fake() quan treballes amb el patró command bus:

use App\Jobs\ImportProducts;
use App\Jobs\SendWelcomeEmail;
use Illuminate\Support\Facades\Bus;
 
test('import dispatches correct jobs', function () {
    Bus::fake();
 
    $this->actingAs(User::factory()->admin()->create())
        ->post('/admin/import', [
            'file' => UploadedFile::fake()->create('products.csv'),
        ]);
 
    Bus::assertDispatched(ImportProducts::class);
});
 
// Verificar batch de jobs
test('monthly billing dispatches batch', function () {
    Bus::fake();
 
    $users = User::factory()->count(10)->create();
 
    Artisan::call('billing:monthly');
 
    Bus::assertBatched(function ($batch) {
        return $batch->jobs->count() === 10
            && $batch->jobs->every(fn ($job) => $job instanceof ProcessPayment);
    });
});

Mocks personalitzats amb Mockery#

Per a serveis que no tenen fake integrat (classes de tercers, APIs externes, serveis personalitzats), pots crear mocks amb Mockery, que ve inclòs amb Laravel:

use App\Services\PaymentGateway;
use App\Services\GeocodeService;
 
test('order processing charges the correct amount', function () {
    // Crear un mock del gateway de pagament
    $gateway = Mockery::mock(PaymentGateway::class);
    $gateway->shouldReceive('charge')
        ->once()
        ->with(9999, 'tok_visa')
        ->andReturn(new PaymentResult(success: true, transactionId: 'txn_123'));
 
    // Registrar el mock al service container
    $this->app->instance(PaymentGateway::class, $gateway);
 
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->post('/orders', [
            'product_id' => 1,
            'payment_token' => 'tok_visa',
        ])
        ->assertRedirect('/orders/confirmation');
});
 
test('handles payment gateway failure gracefully', function () {
    $gateway = Mockery::mock(PaymentGateway::class);
    $gateway->shouldReceive('charge')
        ->once()
        ->andThrow(new PaymentFailedException('Targeta rebutjada'));
 
    $this->app->instance(PaymentGateway::class, $gateway);
 
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->post('/orders', [
            'product_id' => 1,
            'payment_token' => 'tok_declined',
        ])
        ->assertRedirect('/checkout')
        ->assertSessionHas('error', 'El pagament ha fallat. Intenta-ho de nou.');
});
 
test('geocode service is called with correct address', function () {
    $geocoder = Mockery::mock(GeocodeService::class);
    $geocoder->shouldReceive('lookup')
        ->with('Av. Meritxell 28, Andorra la Vella')
        ->once()
        ->andReturn(['lat' => 42.507, 'lng' => 1.521]);
 
    $this->app->instance(GeocodeService::class, $geocoder);
 
    $this->postJson('/api/locations', [
        'address' => 'Av. Meritxell 28, Andorra la Vella',
    ])->assertJson([
        'coordinates' => ['lat' => 42.507, 'lng' => 1.521],
    ]);
});

Spies#

Els spies són com mocks, però no defineixen expectatives per endavant. En lloc de dir "espero que cridis aquest mètode amb aquests paràmetres", deixes que el codi s'executi i després verificas què ha passat. Són útils quan vols verificar efectes secundaris sense ser massa restrictiu:

use App\Services\AnalyticsService;
 
test('page view is tracked', function () {
    $analytics = Mockery::spy(AnalyticsService::class);
    $this->app->instance(AnalyticsService::class, $analytics);
 
    $this->get('/articles/laravel-guide');
 
    // Verificar DESPRÉS de l'execució (spy)
    $analytics->shouldHaveReceived('trackPageView')
        ->once()
        ->with('/articles/laravel-guide');
});
 
// Amb Pest, pots fer spies encara més nets
test('audit log is created for admin actions', function () {
    $logger = Mockery::spy(AuditLogger::class);
    $this->app->instance(AuditLogger::class, $logger);
 
    $admin = User::factory()->admin()->create();
 
    $this->actingAs($admin)
        ->delete('/admin/users/5');
 
    $logger->shouldHaveReceived('log')
        ->with('user.deleted', Mockery::on(function ($data) {
            return $data['user_id'] === 5 && $data['admin_id'] !== null;
        }));
});

Partial Fakes#

De vegades vols fer fake de només alguns mètodes d'un servei i deixar que la resta funcionin normalment. Els partial mocks permeten això:

test('cache is used for expensive queries', function () {
    // Fake parcial: només intercepta mètodes específics del cache
    Cache::shouldReceive('remember')
        ->once()
        ->with('popular_articles', 3600, Mockery::type('Closure'))
        ->andReturn(collect([/* articles cached */]));
 
    $this->get('/articles/popular')
        ->assertOk();
});

Quan fer mock i quan no#

El mocking és una eina poderosa, però abusar-ne pot fer que els tests siguin fràgils i poc fiables. Algunes directrius:

Fer mock de:

  • Serveis externs (APIs, gateways de pagament, serveis de tercers)
  • Operacions costoses (enviament de correus, processament d'imatges)
  • Dependències que introdueixen no-determinisme (temps, aleatorietat)
  • Serveis que tenen efectes secundaris irreversibles (pagaments, enviaments)

NO fer mock de:

  • La base de dades: utilitza RefreshDatabase i factories
  • El propi codi de l'aplicació: testeja la implementació real
  • Coses trivials: no mockis un helper que formata una data
  • Tot: si mockis totes les dependències, el test no prova res real

La regla general és: fes mock de les fronteres del sistema (serveis externs, infraestructura) i testeja la implementació real de la lògica interna de l'aplicació.