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
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
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
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
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
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
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.