Testowanie Laravel z Pest, część 4: Fake'i, czas i granice zewnętrzne

8 min. czytaniaZaktualizowano

Testowanie Laravel z Pest — Część 1 · Część 2 · Część 3 · Część 4 · Część 5

Potwierdzenie zamówienia może wysłać e-mail, zlecić generowanie PDF-a, opublikować zdarzenie, wywołać API księgowości i zapisać fakturę. Test feature, który naprawdę wykonuje wszystkie te kroki, jest wolny, kosztowny, a czasem niebezpieczny. Ale test mockujący każdą zależność przed uruchomieniem aplikacji też wprowadza w błąd: dowodzi jedynie, że Laravel przyjął asercję.

Fake'i Laravela są środkiem między tymi skrajnościami. Zastępują transport na zdefiniowanej granicy i zapisują intencję, która ją przekroczyła. Mail::fake() nie dostarcza maila, lecz może wykazać, jaki mailable został zakolejkowany i do kogo. Http::fake() nie wywołuje dostawcy, lecz może pokazać URL, payload i obsługę odpowiedzi. Granica ma znaczenie: mockuj zewnętrzny efekt uboczny, nigdy decyzję biznesową, która go spowodowała.

Ten artykuł prowadzi jeden checkout przez Mail, Queue, Event, HTTP, Storage i czas. Przykłady używają Pest, ale reguła jest szersza: uruchamiaj logikę aplikacji, którą posiadasz, mockuj transport, którego nie posiadasz, i zostaw kilka wąskich testów integracyjnych spinających własne komponenty.

Uczyń granicę aplikacji obserwowalną

Gdy zamówienie zostaje opłacone, opublikuj zdarzenie domenowe po commicie transakcji bazy danych. Listenery mogą wtedy posiadać niezależną pracę: wysłanie potwierdzenia, wygenerowanie PDF-a lub synchronizację księgowości. Action nadal zmienia prawdziwy stan; w testach zastępowane będą jedynie transporty downstream.

php
<?php

declare(strict_types=1);

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

final class OrderPaid
{
    use Dispatchable;
    use SerializesModels;

    public function __construct(public Order $order)
    {
    }
}
php
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Events\OrderPaid;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class MarkOrderAsPaid
{
    public function handle(Order $order): void
    {
        DB::transaction(function () use ($order): void {
            $order->update(['paid_at' => now()]);

            OrderPaid::dispatch($order)->afterCommit();
        });
    }
}

To wartościowa granica fake'a zdarzeń. Test uruchamia MarkOrderAsPaid, zapisuje decyzję o płatności i weryfikuje zdarzenie z właściwym zamówieniem. Nie potrzebuje workera kolejki, serwera SMTP ani sterownika dysku wyłącznie po to, by ustalić, że płatność przeszła.

Mockuj wąsko i we właściwym momencie

Event::fake() bez allow-listy tłumi wszystkie zdarzenia, także zdarzenia frameworka, których może potrzebować factory, observer albo setup testu. Najpierw utwórz model, następnie zamockuj wyłącznie badane zdarzenie. Ta kolejność zapobiega sytuacji, w której observer po cichu nie tworzy danych, od których zależy test.

php
<?php

use App\Actions\MarkOrderAsPaid;
use App\Events\OrderPaid;
use App\Models\Order;
use Illuminate\Support\Facades\Event;

it('marks an order as paid and publishes the follow-up event', function () {
    $order = Order::factory()->create(['paid_at' => null]);

    Event::fake([OrderPaid::class]);

    app(MarkOrderAsPaid::class)->handle($order);

    expect($order->refresh()->paid_at)->not->toBeNull();

    Event::assertDispatched(OrderPaid::class, function (OrderPaid $event) use ($order): bool {
        return $event->order->is($order);
    });
});

Callback ma znaczenie. Samo assertDispatched(OrderPaid::class) mówi, że zdarzenie istniało; nie dowodzi, że było związane z tym zamówieniem. Fake zdarzenia nie udowodni też, że listener jest zarejestrowany ani że worker kiedyś przetworzy job. To inne granice i potrzebują innych testów.

Fake'i Mail i Queue dowodzą intencji dostarczenia

Mailable i joby są normalnie kolejkowane. W teście na poziomie requestu zamockuj obie warstwy i sprawdź odbiorcę oraz payload. Nie asercjuj każdej wewnętrznej zmiennej widoku: sprawdzaj informacje mające znaczenie na granicy dla klienta lub workera.

php
<?php

use App\Jobs\GenerateInvoicePdf;
use App\Mail\OrderReceipt;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

it('queues a receipt and invoice work for a paid order', function () {
    $order = Order::factory()->forCustomer()->create();

    Mail::fake();
    Queue::fake();

    $this->post(route('orders.pay', $order))->assertRedirect();

    Mail::assertQueued(OrderReceipt::class, function (OrderReceipt $mail) use ($order): bool {
        return $mail->hasTo($order->customer->email)
            && $mail->order->is($order);
    });

    Queue::assertPushed(GenerateInvoicePdf::class, function (GenerateInvoicePdf $job) use ($order): bool {
        return $job->order->is($order);
    });
});

Użyj Mail::assertQueued() dla mailable'a implementującego ShouldQueue; po assertSent() sięgaj tylko, gdy wysyłka ma być synchroniczna. Queue::fake() dowodzi dispatchu, a nie działania GenerateInvoicePdf::handle(). Nadaj jobowi skupiony test wykonujący jego prawdziwy kod z fałszywym dyskiem.

php
<?php

use App\Jobs\GenerateInvoicePdf;
use App\Models\Order;
use Illuminate\Support\Facades\Storage;

it('stores a generated invoice on the private invoice disk', function () {
    Storage::fake('invoices');
    $order = Order::factory()->paid()->create();

    app(GenerateInvoicePdf::class)->handle($order);

    Storage::disk('invoices')->assertExists("{$order->id}/invoice.pdf");
    Storage::disk('invoices')->assertMissing("{$order->id}/invoice-draft.pdf");
});

Dysk jest częścią kontraktu bezpieczeństwa: faktura nie może trafić na publiczny dysk. To mocniejsze niż asercja, że mock raz otrzymał put(). Testy uploadu podobnie łączą UploadedFile::fake()->create() z Storage::fake(), więc nigdy nie zależą od plików po poprzednim przebiegu.

Uczyń fake HTTP restrykcyjnym

Ręcznie napisany mock klienta HTTP często kopiuje jego implementację i nigdy nie sprawdza prawdziwego requestu. Fake Laravela zapisuje request po przekroczeniu granicy frameworka. Połącz go z preventStrayRequests(), aby nieskonfigurowany URL zakończył test zamiast kontaktować prawdziwy serwis.

php
<?php

use App\Actions\SyncOrderToAccounting;
use App\Models\Order;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

it('sends an accounting payload with a stable external reference', function () {
    Http::preventStrayRequests();
    Http::fake([
        'https://accounting.example.test/api/invoices' => Http::response(['id' => 'inv_123'], 201),
    ]);
    $order = Order::factory()->paid()->create(['external_reference' => 'ORD-2026-0042']);

    app(SyncOrderToAccounting::class)->handle($order);

    Http::assertSent(function (Request $request) use ($order): bool {
        return $request->url() === 'https://accounting.example.test/api/invoices'
            && $request['reference'] === $order->external_reference
            && $request['total_cents'] === $order->total_cents;
    });
});

Dodaj jawne odpowiedzi fake dla timeoutu, 422 i 500, jeśli przypadki prowadzą do odmiennego zachowania biznesowego. Test zwracający wyłącznie 200 nie potwierdzi retry, logowania ani widocznego stanu nieudanej synchronizacji. Nie używaj jednak fake'a HTTP do certyfikowania prawdziwego schematu dostawcy. Do poświadczeń, nagłówków i zachowania vendora zachowaj mały suite sandboxowy lub kontraktowy, osobno skonfigurowany i niewykonywany w każdym pull requeście.

Podróżuj w czasie dla granic biznesowych

Logika wygaśnięcia jest niestabilna, gdy pyta, czy coś jest ważne „teraz”. Ustal zegar i przetestuj obie strony granicy. Pomocniki podróży w czasie Laravela resetują zegar po teście; travelBack() jest nadal użyteczne, gdy test wraca do prawdziwego czasu przed końcem.

php
<?php

use App\Models\DownloadLink;
use Illuminate\Support\Carbon;

it('rejects an expired download link', function () {
    $this->travelTo(Carbon::parse('2026-09-06 10:00:00'));
    $link = DownloadLink::factory()->create(['expires_at' => now()->addMinutes(15)]);

    $this->travel(16)->minutes();

    $this->get(route('downloads.show', $link))->assertGone();

    $this->travelBack();
});

Zamrażaj czas dla wygaśnięcia, pracy harmonogramu i polityki zależnej od daty — nie dla ozdobnych timestampów. Jeżeli wynik nie zależy od czasu, asercja dokładnego created_at wiąże test z nieistotnym detalem. Ręczne Carbon::setTestNow() zawsze wyczyść; wyciek czasu tworzy awarie zależne od kolejności suite'a.

Kontrprzykład integracyjny

Łatwo stworzyć dwa zielone testy jednostkowe i pominąć zerwane połączenie. Test kontrolera z Event::fake() dowodzi emisji OrderPaid. Test jobu dowodzi zapisu pliku. Żaden nie dowodzi, że listener OrderPaid dispatchuje job faktury. Zachowaj jeden wąski test integracyjny przepuszczający zdarzenie i listener, ale mockujący końcowy transport kolejki.

php
<?php

use App\Actions\MarkOrderAsPaid;
use App\Jobs\GenerateInvoicePdf;
use App\Models\Order;
use Illuminate\Support\Facades\Queue;

it('connects payment, the order-paid listener, and invoice dispatch', function () {
    Queue::fake();
    $order = Order::factory()->create(['paid_at' => null]);

    app(MarkOrderAsPaid::class)->handle($order);

    Queue::assertPushed(GenerateInvoicePdf::class, function (GenerateInvoicePdf $job) use ($order): bool {
        return $job->order->is($order);
    });
});

Ten test łapie brak rejestracji listenera i przypadkową zmianę payloadu, pozostając szybkim. Zwykle wystarczy jeden lub dwa testy w tej formie. Zmienienie każdego testu feature w pełny test integracyjny daje wolne awarie, których źródło trudno znaleźć.

Kiedy nie mockować i co najczęściej zawodzi

Nie mockuj czystych obliczeń, walidacji ani autoryzacji: wywołaj prawdziwy serwis i sprawdź zwracaną wartość lub odpowiedź HTTP. Nie zastępuj fake'ami testów przeglądarkowych, gdy znaczenie ma javascriptowe zachowanie uploadu, ani kontrolowanego testu sandbox dostawcy. Użyteczna piramida to wiele szybkich testów z fałszywymi transportami, kilka testów integracyjnych łączących własne komponenty i celowo mały suite zewnętrzny.

Najczęstsze pułapki to zbyt wczesne fake'i, mockowanie klasy testowanej i asercje samych liczników. Twórz dane z factory przed mockowaniem zdarzeń zależnych od observera. Jeśli adapterem jest SyncOrderToAccounting, mockuj HTTP pod nim — nie sam adapter — aby URL, payload, retry i obsługa odpowiedzi pozostały prawdziwe. Na koniec używaj callbacków asercji: assertPushed(Job::class) mówi, że istniał job, nie że należy do bieżącego zamówienia.

Fake'i przyspieszają testy przez usuwanie niepewności poza aplikacją. Nie powinny usuwać całej niepewności. Zostaw logikę biznesową prawdziwą, wyrażaj jasno zewnętrzną intencję i zachowaj tyle pokrycia integracyjnego, by wiedzieć, że granice nadal do siebie pasują.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.