Testowanie Laravel z Pest, część 1: Setup, konfiguracja i factories

6 min. czytaniaZaktualizowano

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

Testy są drugą aplikacją. Potrzebują bazy danych, cache'a, mailera, kolejki i systemu plików, które nie mogą przypadkowo dotknąć danych deweloperskich. W tej pierwszej części budujemy tę granicę wokół niewielkiej domeny zamówień, do której będziemy wracać w całej serii: klient składa zamówienie, rezerwowany jest stan magazynowy, a zamówienie może zostać opłacone. Część 2 przeprowadzi ten przypadek przez HTTP, część 3 przez przeglądarkę, a ostatnie części uczynią czas i CI deterministycznymi.

Celem nie jest to, aby każdy test korzystał z bazy danych. Chodzi o to, aby testy, które jej używają, były nudne: czytelnik powinien widzieć sytuację biznesową w pierwszych kilku liniach i nie musieć rozszyfrowywać przypadkowego setupu.

Zapewnij testom własne usługi

Laravel odczytuje wartości z phpunit.xml podczas uruchamiania testów. Korzystaj z usług, które można bezpiecznie wyrzucić lokalnie i w CI. SQLite w pamięci jest szybki, ale nie jest idealnym odpowiednikiem MySQL ani PostgreSQL: JSON, porównywanie tekstu, blokady i funkcje SQL mogą się różnić. Używaj go do szybkiego feedbacku z aplikacji tylko wtedy, gdy zapytania są przenośne; w przeciwnym razie uruchamiaj testową bazę na tym samym silniku co produkcja.

xml
<!-- phpunit.xml -->
<php>
    <env name="APP_ENV" value="testing"/>
    <env name="BCRYPT_ROUNDS" value="4"/>
    <env name="CACHE_STORE" value="array"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="SESSION_DRIVER" value="array"/>
</php>

sync przydaje się, gdy sprawdzasz prosty efekt uboczny, ale nie myl go z pokryciem kolejki: job, który działa wyłącznie synchronicznie, może nadal zawieść po serializacji na workerze. W części 4 użyjemy Queue::fake(), gdy kontrakt brzmi „job został wysłany”, oraz testów integracyjnych, gdy istotne jest zachowanie samego workera.

Trzymaj wspólny setup widoczny w tests/Pest.php:

php
<?php

use Illuminate\Foundation\Testing\LazilyRefreshDatabase;

uses(Tests\TestCase::class, LazilyRefreshDatabase::class)->in('Feature');

LazilyRefreshDatabase uruchamia migracje tylko wtedy, gdy test faktycznie dotyka bazy danych. Wybierz je zamiast ręcznego czyszczenia tabel. Test, który tworzy model, ale pomija ten trait, może przejść pojedynczo i zostawić stan dla reszty suite'u.

Modeluj historię zamówienia w factories

Factories są słownikiem, a nie generatorem losowych danych. Zacznij od wartości domyślnych, które oznaczają poprawny, zwyczajny obiekt. Następnie nazwij wyjątkowe stany biznesowe, o których test ma mówić.

php
<?php

namespace Database\Factories;

use App\Models\Customer;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<Order> */
final class OrderFactory extends Factory
{
    public function definition(): array
    {
        return [
            'customer_id' => Customer::factory(),
            'number' => fake()->unique()->numerify('ORD-#####'),
            'status' => 'draft',
            'total_cents' => 0,
            'placed_at' => null,
            'paid_at' => null,
        ];
    }

    public function placed(): static
    {
        return $this->state(fn (): array => ['status' => 'placed', 'placed_at' => now()]);
    }

    public function paid(): static
    {
        return $this->placed()->state(fn (): array => ['status' => 'paid', 'paid_at' => now()]);
    }

    public function withLine(Product $product, int $quantity = 1): static
    {
        return $this->afterCreating(function (Order $order) use ($product, $quantity): void {
            $order->lines()->create(['product_id' => $product->id, 'quantity' => $quantity, 'unit_price_cents' => $product->price_cents]);
        });
    }
}

Helper withLine() celowo tworzy utrwaloną relację po tym, gdy istnieje już zamówienie. Factory relacji jest równie dobre, gdy jego struktura jest prosta; wybierz podejście, które czyni miejsce użycia najczytelniejszym. Nie każ domyślnej factory tworzyć linii, płatności i powiadomień „na wszelki wypadek”. Wtedy fixture z jednym wierszem zamienia się w ukryte I/O, a niepowiązane testy zwalniają.

php
<?php

use App\Models\Order;
use App\Models\Product;

it('builds a paid order with an explicit line', function (): void {
    $product = Product::factory()->create(['price_cents' => 2_500]);
    $order = Order::factory()->paid()->withLine($product, quantity: 2)->create();

    expect($order->status)->toBe('paid')
        ->and($order->lines)->toHaveCount(1)
        ->and($order->lines->first()->quantity)->toBe(2);
});

Zachowaj intencję w miejscu użycia

Unikaj $order = Order::factory()->create(['status' => 'paid', 'paid_at' => now()]);. Powiela on znaczenie płatności w całym suite'cie. Gdy definicja się zmieni — na przykład będzie wymagać referencji płatności — stare testy po cichu utworzą niepoprawne obiekty. ->paid() zapewnia jedno miejsce migracji i czyta się jak domena. Z drugiej strony, nie twórz stanów dla jednorazowych kosmetycznych nadpisań: ->state(['number' => 'ORD-42']) jest czytelniejsze niż ->numberFortyTwo().

Datasety są przydatne wyłącznie dla powtarzającej się reguły, a nie jako sposób na ukrycie scenariusza:

php
it('rejects invalid quantities', function (int $quantity): void {
    expect($quantity)->toBeLessThan(1);
})->with([-1, 0]);

Czego ten setup nie testuje

Factories nie dowodzą poprawności migracji, przepływów w przeglądarce, serializacji workera ani prawdziwego dostawcy płatności. Umożliwiają skupione testy funkcjonalne. Nie używaj LazilyRefreshDatabase dla czystego value objectu ani kalkulacji cen; takie testy powinny konstruować obiekty bezpośrednio i kończyć się w milisekundach. Nie wciskaj też factories do starszej domeny bez stabilnych niezmienników: niewielki test builder może być uczciwszą granicą.

W części 2 ten sam język paid() i withLine() sprawi, że test HTTP będzie dotyczył autoryzacji i odpowiedzi, a nie składania zamówienia.

Testuj także kontrakt factory

Factory jest elementem testowej infrastruktury bliskim produkcji. Zapewnij ważnym stanom niewielki test regresji, szczególnie gdy callback afterCreating tworzy relacje lub aktualizuje sumy. To wychwytuje mylącą sytuację, w której test funkcjonalny zawodzi dlatego, że jego fixture stał się niepoprawny, a nie dlatego, że funkcja uległa regresji.

php
<?php

use App\Models\Order;
use App\Models\Product;

it('creates a line at the product price', function (): void {
    $product = Product::factory()->create(['price_cents' => 1_999]);
    $order = Order::factory()->withLine($product, quantity: 3)->create();

    expect($order->fresh()->lines()->firstOrFail())
        ->unit_price_cents->toBe(1_999)
        ->quantity->toBe(3);
});

Używaj stałych wartości zawsze, gdy asercja dotyczy arytmetyki, wygaśnięcia, kolejności sortowania lub publicznej odpowiedzi. Faker świetnie nadaje się do pól, których zawartość nie ma znaczenia, ale słabo sprawdza się jako sposób ukrywania danych wejściowych testu. Jeżeli nieudany test nie pozwala czytelnikowi określić, która cena, klient czy data są ważne, debugowanie jest trudniejsze, niż musi być. Nie wykorzystuj tej struktury jako wymówki dla jednostkowych testów obciążonych bazą danych. Czysty kalkulator wartości zamówienia powinien otrzymać kolekcję linii w pamięci. Test funkcjonalny powinien potwierdzać integrację Eloquent, policies i utrwalanie danych. To rozróżnienie sprawia, że docelowy suite pozostaje wystarczająco szybki, aby uruchamiać go przed każdym commitem.

Podczas pisania uruchamiaj najmniejsze istotne polecenie, a przed mergem szerszy suite tej serii:

sh
docker compose exec app php artisan test --compact tests/Feature/OrderFactoryTest.php
docker compose exec app php artisan test --compact --filter='paid order'

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.