Testowanie Laravel z Pest, część 2: HTTP i feature testy

8 min. czytaniaZaktualizowano

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

Test jednostkowy odpowiada na pytanie, czy mała klasa zachowuje się poprawnie. Feature test odpowiada na pytanie ważniejsze dla aplikacji webowej: czy prawdziwy klient może bezpiecznie użyć tego endpointu? Laravel uruchamia aplikację, dopasowuje trasę, wykonuje middleware, rozwiązuje kontroler i zapisuje dane w testowej bazie. To szerszy przekrój niż unit test, ale wciąż wystarczająco szybki, by należał do codziennego feedback loopa.

Zbudujemy endpoint, przez który opiekun konta tworzy zamówienie dla własnego konta. Sprawdzimy publiczny kontrakt, nie wnętrze kontrolera: uwierzytelnienie, autoryzację, walidację, odpowiedź i efekt w bazie. Przykład celowo używa klasy akcji, aby granica HTTP była mała, ale test kontrolera nie stał się ćwiczeniem z mocków.

Zacznij od kontraktu zrozumiałego dla klienta

Opiekun konta wysyła SKU produktu oraz ilość na URL ograniczony do konta. Trasa jasno nazywa granicę zasobu: zamówienie należy do konta, nie do sesji przeglądarki ani kontrolera.

php
<?php

use App\Http\Controllers\AccountOrderController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function (): void {
    Route::post('/accounts/{account}/orders', AccountOrderController::class)
        ->name('accounts.orders.store');
});

Najpierw utrwal w teście URL i oczekiwaną odpowiedź, dopiero potem optymalizuj implementację. Klient potrzebuje 201 Created i stabilnego body. Nie musi wiedzieć, czy wewnątrz zastosowano Order::create(), czy klasę akcji.

Relacje w przykładzie są konwencjonalne: Account ma wielu użytkowników i wiele zamówień; Order zapisuje account_id, product_id, quantity, status oraz created_by. Produkt ma SKU i cenę w centach. Factories modeli pozwalają testowi opisać wyłącznie fakty, których potrzebuje.

Waliduj dane na granicy HTTP

Form request czyni błędne dane wejściowe pełnoprawną częścią kontraktu endpointu. Akcja otrzymuje przez to tylko dane o sprawdzonym kształcie. Nie powielaj reguł w kontrolerze i teście; testuj widoczne dla klienta błędy walidacji.

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class StoreAccountOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('createOrder', $this->route('account')) ?? false;
    }

    /** @return array<string, list<string>> */
    public function rules(): array
    {
        return [
            'product_sku' => ['required', 'string', 'exists:products,sku'],
            'quantity' => ['required', 'integer', 'min:1', 'max:500'],
        ];
    }
}

Request nie zastępuje niezmienników domeny. Limit 500 jest ograniczeniem wejścia. To, czy konto może zamówić wycofany produkt albo przekroczyć limit kredytowy, jest polityką biznesową. Powinna leżeć bliżej operacji tworzenia zamówienia, aby job kolejki, polecenie CLI lub inny endpoint API nie mógł jej ominąć.

Ukryj zapis i reguły domenowe za jedną operacją

Akcja odnajduje produkt, egzekwuje regułę biznesową i tworzy zamówienie. W prawdziwym checkoutcie może też rezerwować stan magazynowy lub tworzyć event outboxa; użyj transakcji bazy, gdy kilka zapisów musi wspólnie się powieść albo wycofać. Tu jeden zapis wystarcza, lecz granica zależności już jest przydatna.

php
<?php

declare(strict_types=1);

namespace App\Actions\Orders;

use App\Models\Account;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Validation\ValidationException;

final class CreateAccountOrder
{
    public function handle(Account $account, User $creator, string $productSku, int $quantity): Order
    {
        $product = Product::query()->where('sku', $productSku)->firstOrFail();

        if (! $product->is_orderable) {
            throw ValidationException::withMessages([
                'product_sku' => 'This product is not available for ordering.',
            ]);
        }

        return Order::query()->create([
            'account_id' => $account->id,
            'product_id' => $product->id,
            'quantity' => $quantity,
            'status' => 'pending',
            'created_by' => $creator->id,
        ]);
    }
}

Wyjątek ma celowo kształt błędu walidacji. Klient API ma wówczas jeden, przewidywalny sposób pokazania odrzucenia pola, niezależnie od tego, czy SKU nie podano, czy produkt stał się niedostępny między załadowaniem strony a wysłaniem formularza. Dla reguły niezwiązanej z polem, na przykład blokady kredytowej, czytelniejszy może być 409 Conflict lub resource problem details. Najważniejsze jest świadome wybranie i przetestowanie publicznego rezultatu.

Niech kontroler będzie warstwą tłumaczącą

Kontroler invokable tłumaczy input HTTP na operację aplikacyjną, a jej wynik na odpowiedź HTTP. Nie powinien powtarzać walidacji, decyzji policy ani szczegółów zapytania.

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\Orders\CreateAccountOrder;
use App\Http\Requests\StoreAccountOrderRequest;
use App\Models\Account;
use Illuminate\Http\JsonResponse;

final class AccountOrderController
{
    public function __invoke(
        StoreAccountOrderRequest $request,
        Account $account,
        CreateAccountOrder $createAccountOrder,
    ): JsonResponse {
        $order = $createAccountOrder->handle(
            $account,
            $request->user(),
            $request->string('product_sku')->toString(),
            $request->integer('quantity'),
        );

        return response()->json([
            'data' => [
                'id' => $order->id,
                'status' => $order->status,
                'quantity' => $order->quantity,
            ],
        ], 201);
    }
}

Zwróć uwagę, czego test HTTP nie powinien sprawdzać: że CreateAccountOrder wywołano dokładnie raz. Taka asercja wiąże test z prywatną współpracą klas i sprawia, że bezpieczny refaktoring wygląda jak regresja. Testuj obserwowalne zamówienie.

Przetestuj poprawne żądanie od strony klienta

Helpery HTTP Pesta zwięźle pokazują intencję. RefreshDatabase daje każdemu testowi znany stan bazy, a factories czynią relację autoryzacyjną jawną.

php
<?php

use App\Models\Account;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('creates a pending order for an account manager', function (): void {
    $account = Account::factory()->create();
    $manager = User::factory()->for($account)->create();
    $product = Product::factory()->create([
        'sku' => 'SUPPORT-20H',
        'is_orderable' => true,
    ]);

    $response = $this->actingAs($manager)->postJson(
        route('accounts.orders.store', $account),
        ['product_sku' => $product->sku, 'quantity' => 3],
    );

    $response
        ->assertCreated()
        ->assertJsonPath('data.status', 'pending')
        ->assertJsonPath('data.quantity', 3);

    $this->assertDatabaseHas(Order::class, [
        'account_id' => $account->id,
        'product_id' => $product->id,
        'quantity' => 3,
        'status' => 'pending',
        'created_by' => $manager->id,
    ]);
});

Asercja bazy jest konieczna. 201 i JSON body mogą wrócić nawet wtedy, gdy implementacja omyłkowo pominie zapis, zapisze zły klucz obcy albo zleci pracę kolejce, która nigdy nie utrwali zamówienia. Z drugiej strony nie sprawdzaj każdej kolumny: znaczniki czasu i pola wyłącznie implementacyjne niepotrzebnie usztywniają testy.

Traktuj autoryzację i walidację jako osobne zachowania

Jeden przechodzący happy path nie dowodzi, że granica konta jest bezpieczna. Uwierzytelniony opiekun nie może tworzyć zamówienia dla innego konta po samej zmianie segmentu URL. Przetestuj to wprost, a błędne dane sprawdzaj u użytkownika, który ma prawo dotrzeć do walidacji.

php
<?php

use App\Models\Account;
use App\Models\Order;
use App\Models\User;

it('forbids a manager from ordering for another account', function (): void {
    $ownAccount = Account::factory()->create();
    $otherAccount = Account::factory()->create();
    $manager = User::factory()->for($ownAccount)->create();

    $this->actingAs($manager)
        ->postJson(route('accounts.orders.store', $otherAccount), [
            'product_sku' => 'SUPPORT-20H',
            'quantity' => 3,
        ])
        ->assertForbidden();

    $this->assertDatabaseCount(Order::class, 0);
});

it('returns field errors and creates no order for invalid input', function (): void {
    $account = Account::factory()->create();
    $manager = User::factory()->for($account)->create();

    $this->actingAs($manager)
        ->postJson(route('accounts.orders.store', $account), [
            'product_sku' => 'UNKNOWN',
            'quantity' => 0,
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['product_sku', 'quantity']);

    $this->assertDatabaseCount(Order::class, 0);
});

Kolejność ma znaczenie. Laravel wykonuje autoryzację przed walidacją w form requeście. Jeśli test zabronionego dostępu oczekuje też błędów walidacji, uczy nieprawdziwego kontraktu i może ujawnić, które SKU istnieją. Każdy test powinien mieć jeden powód porażki.

Częste pułapki feature testów

Pierwsza pułapka to sprawdzanie policy wyłącznie unit testem modelu. Testy policy są przydatne, ale trasa nadal może nie mieć auth, bindować inny parametr albo pytać o złą ability. Krótki HTTP test dla 403 łapie to połączenie elementów.

Druga to poleganie na seedach, ID albo globalnie uwierzytelnionym użytkowniku. Testy przechodzą lokalnie, bo baza przypadkiem ma oczekiwane rekordy, a zawodzą przy wykonaniu równoległym. Twórz konto, użytkownika i produkt w teście; niech relacja przyznająca dostęp będzie widoczna w sekcji arrange.

Trzecia pułapka to mockowanie Eloquent albo akcji w feature teście. Mocki są właściwe, gdy trzeba odizolować zewnętrzną bramkę HTTP lub zegar, ale zastąpienie ścieżki zapisu usuwa integrację bazy i serializacji, którą ten test ma objąć. Zostaw prawdziwą akcję, a oddzielne unit testy dodaj dopiero, gdy jej logika rozgałęzień nabierze znaczenia.

Nie zamieniaj też feature testu w suite end-to-end. Testy przeglądarkowe powinny obejmować JavaScript, fokus formularza i interakcję Inertia albo Livewire. Feature test jest szybszy i precyzyjniejszy dla serwerowego kontraktu HTTP. Używaj obu warstw, gdy ryzyko to uzasadnia.

Kiedy nie używać feature testu

Nie używaj tej warstwy, by udowodnić każdy wariant obliczeń czystego kalkulatora ceny, każdy przypadek brzegowy daty w value object czy wszystkie kombinacje złożonej polityki kredytowej. Takie przypadki są tańsze i czytelniejsze jako unit testy. Nie służy też do sprawdzania, czy zewnętrzny dostawca płatności przyjmie żądanie; potrzebny jest test kontraktowy lub sandbox z jawnymi poświadczeniami i osobną grupą wykonawczą.

Feature test stosuj, gdy routing, middleware, walidacja requestu, autoryzacja, model binding, trwałość danych i kształt odpowiedzi muszą razem działać poprawnie. Mały zestaw reprezentatywnych testów HTTP daje zaufanie do tej granicy. Uzupełnij go wieloma skupionymi unit testami poniżej oraz kilkoma testami przeglądarkowymi powyżej, zamiast oczekiwać od jednej warstwy każdego rodzaju pewności.

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.