Was ist Pest?
Pest ist ein auf PHPUnit aufbauendes Test-Framework und in neuen Projekten ab Laravel 11 die Standardwahl. Sie können die umfangreichen Assertions von PHPUnit weiter nutzen und Tests dazu in prägnanter, Closure-basierter Syntax schreiben.
Wichtige Unterschiede zu PHPUnit:
Aspekt Pest PHPUnit Testdefinition test() / it() mit ClosureKlasse mit Methoden Assertions Expectation API (expect()->toBe()) $this->assert*()Datasets dataset() / with()@dataProviderHooks beforeEach() / afterEach()setUp() / tearDown()Nesting Gruppierung mit describe() Trennung über Klassen
Pest-Tests laufen auf PHPUnit — bestehende PHPUnit-Tests können nebeneinander bestehen. Ausführen mit php artisan test oder vendor/bin/pest.
describe / it / test sinnvoll einsetzen
test()
Die einfachste Form. Der Testname dient als Beschreibung.
test ( 'Nutzer kann sich mit E-Mail-Adresse anmelden' , function () {
$user = User :: factory () -> create ();
$response = $this -> post ( '/login' , [
'email' => $user -> email ,
'password' => 'password' ,
]);
$response -> assertRedirect ( '/dashboard' );
});
it()
Erlaubt eine natürlichere „it should …”-Formulierung.
it ( 'leitet nicht authentifizierte Nutzer auf die Login-Seite um' , function () {
$response = $this -> get ( '/dashboard' );
$response -> assertRedirect ( '/login' );
});
describe()
Gruppiert zusammengehörige Tests. Praktisch zum Teilen von beforeEach() und zur logischen Gliederung.
describe ( 'Bestellverwaltung' , function () {
beforeEach ( function () {
$this -> user = User :: factory () -> create ();
$this -> actingAs ( $this -> user );
});
it ( 'kann die Bestellliste abrufen' , function () {
Order :: factory ( 3 ) -> for ( $this -> user ) -> create ();
$response = $this -> get ( '/orders' );
$response -> assertOk () -> assertJsonCount ( 3 , 'data' );
});
it ( 'kann keine Bestellungen anderer Nutzer abrufen' , function () {
$other = User :: factory () -> create ();
Order :: factory () -> for ( $other ) -> create ();
$response = $this -> get ( '/orders' );
$response -> assertOk () -> assertJsonCount ( 0 , 'data' );
});
});
describe() lässt sich verschachteln. Zu tiefe Verschachtelung erschwert die Lesbarkeit — als Faustregel maximal zwei Ebenen.
Expectation API
expect() ist Pests eigene Assertion-Syntax mit Method-Chains.
Grundlegende Assertions
expect ( $value ) -> toBe ( 42 ); // strikte Gleichheit (===)
expect ( $value ) -> toEqual ([ 'a' => 1 ]); // lose Gleichheit (==)
expect ( $value ) -> toBeTrue ();
expect ( $value ) -> toBeFalse ();
expect ( $value ) -> toBeNull ();
expect ( $value ) -> not -> toBeNull ();
expect ( $string ) -> toContain ( 'Laravel' );
expect ( $array ) -> toHaveCount ( 3 );
expect ( $array ) -> toHaveKey ( 'email' );
expect ( $array ) -> toContain ( 'admin' );
expect ( $number ) -> toBeGreaterThan ( 0 );
expect ( $number ) -> toBeLessThanOrEqual ( 100 );
Assertions auf Modellen
expect ( $user ) -> toBeInstanceOf ( User :: class );
expect ( $user -> email ) -> toMatchRegex ( '/ ^ . + @. + \. . +$ /' );
// Datenbank-Persistenz prüfen
expect ( User :: where ( 'email' , '[email protected] ' ) -> exists ()) -> toBeTrue ();
and()-Chain
expect ( $response -> status ()) -> toBe ( 200 )
-> and ( $response -> json ( 'name' )) -> toBe ( 'Laravel' )
-> and ( $response -> json ( 'version' )) -> toBeGreaterThan ( 12 );
each() — jedes Array-Element prüfen
$users = User :: factory ( 3 ) -> create ();
expect ( $users ) -> each ( function ( $user ) {
$user -> toBeInstanceOf ( User :: class )
-> email -> not -> toBeNull ();
});
Parametrisierte Tests mit Datasets
Um dieselbe Logik mit mehreren Eingaben zu testen, verwenden Sie dataset() oder inline with().
Inline-Dataset
it ( 'markiert ungültige E-Mail-Adressen als Validierungsfehler' , function ( string $email ) {
$response = $this -> post ( '/register' , [ 'email' => $email ]);
$response -> assertInvalid ( 'email' );
}) -> with ([
'Klartext' => [ 'not-an-email' ],
'ohne Domain' => [ 'user@' ],
'Leerstring' => [ '' ],
]);
Benannte Datasets
Definieren Sie Datasets im Verzeichnis tests/Datasets.
// tests/Datasets/InvalidEmails.php
dataset ( 'invalid_emails' , [
'plain' => [ 'not-an-email' ],
'no-domain' => [ 'user@' ],
'empty' => [ '' ],
'spaces' => [ 'user @example.com' ],
]);
it ( 'markiert ungültige E-Mail-Adressen als Validierungsfehler' , function ( string $email ) {
$response = $this -> post ( '/register' , [ 'email' => $email ]);
$response -> assertInvalid ( 'email' );
}) -> with ( 'invalid_emails' );
Dynamische Datasets über Closures
it ( 'hat pro Nutzerplan unterschiedliche Limits' , function ( string $plan , int $limit ) {
$user = User :: factory () -> create ([ 'plan' => $plan ]);
expect ( $user -> requestLimit ()) -> toBe ( $limit );
}) -> with ([
[ 'free' , 100 ],
[ 'pro' , 1000 ],
[ 'enterprise' , 10000 ],
]);
Unit-Tests mit Mockery
Service mocken
use App\Services\ PaymentGateway ;
use Mockery\ MockInterface ;
test ( 'Der Payment-Service wird aufgerufen' , function () {
$mock = $this -> mock ( PaymentGateway :: class , function ( MockInterface $mock ) {
$mock -> expects ( 'charge' )
-> with ( 1000 , 'jpy' )
-> andReturn ([ 'status' => 'succeeded' ]);
});
$result = app ( PaymentGateway :: class ) -> charge ( 1000 , 'jpy' );
expect ( $result [ 'status' ]) -> toBe ( 'succeeded' );
});
Spies
Spies führen die reale Implementierung aus und protokollieren Aufrufe.
use App\Services\ NotificationService ;
test ( 'Der Notification-Service wurde aufgerufen' , function () {
$spy = $this -> spy ( NotificationService :: class );
$this -> post ( '/orders' , [ 'amount' => 1000 ]);
$spy -> shouldHaveReceived ( 'send' ) -> once () -> with ( 'order.created' );
});
Partial Mocks
Nur einzelne Methoden mocken, den Rest real ausführen.
use App\Services\ ReportService ;
use Mockery\ MockInterface ;
test ( 'Dateischreiben des Reports überspringen' , function () {
$mock = $this -> partialMock ( ReportService :: class , function ( MockInterface $mock ) {
$mock -> expects ( 'writeFile' ) -> andReturnNull ();
});
$result = $mock -> generate ();
expect ( $result ) -> not -> toBeNull ();
});
Tests externer API-Aufrufe mit HTTP-Fakes
Http::fake() stubbt Responses, sodass keine echten HTTP-Requests gesendet werden.
Grundlegender Fake
use Illuminate\Support\Facades\ Http ;
test ( 'Nutzerinformationen aus externer API laden' , function () {
Http :: fake ([
'https://api.example.com/users/*' => Http :: response ([
'id' => 1 ,
'name' => 'Laravel User' ,
], 200 ),
]);
$result = app ( UserApiClient :: class ) -> find ( 1 );
expect ( $result [ 'name' ]) -> toBe ( 'Laravel User' );
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://api.example.com/users/1' );
});
Fehler-Response testen
test ( 'Wirft Exception, wenn die API einen Fehler liefert' , function () {
Http :: fake ([
'api.example.com/*' => Http :: response ([], 503 ),
]);
expect ( fn () => app ( UserApiClient :: class ) -> find ( 1 ))
-> toThrow ( \App\Exceptions\ ApiUnavailableException :: class );
});
Netzwerkfehler simulieren
use Illuminate\Http\Client\ ConnectionException ;
test ( 'behandelt Verbindungsfehler' , function () {
Http :: fake ( fn () => throw new ConnectionException ( 'Connection refused' ));
$result = app ( UserApiClient :: class ) -> findWithFallback ( 1 );
expect ( $result ) -> toBeNull ();
});
Fakes für Events, Mails und Notifications
Event::fake()
use Illuminate\Support\Facades\ Event ;
use App\Events\ OrderCreated ;
test ( 'Beim Erstellen einer Bestellung wird ein Event ausgelöst' , function () {
Event :: fake ();
$this -> post ( '/orders' , [ 'product_id' => 1 , 'amount' => 1000 ]);
Event :: assertDispatched ( OrderCreated :: class , function ( $event ) {
return $event -> order -> amount === 1000 ;
});
});
Sie können auch nur bestimmte Events faken und andere real verarbeiten lassen.
Event :: fake ([ OrderCreated :: class ]);
Mail::fake()
use Illuminate\Support\Facades\ Mail ;
use App\Mail\ WelcomeEmail ;
test ( 'Bei Registrierung wird eine Willkommens-Mail verschickt' , function () {
Mail :: fake ();
$this -> post ( '/register' , [
'name' => 'Test User' ,
'email' => '[email protected] ' ,
'password' => 'password' ,
'password_confirmation' => 'password' ,
]);
Mail :: assertSent ( WelcomeEmail :: class , function ( $mail ) {
return $mail -> hasTo ( '[email protected] ' );
});
});
Notification::fake()
use Illuminate\Support\Facades\ Notification ;
use App\Notifications\ OrderShipped ;
test ( 'Beim Versand wird eine Benachrichtigung gesendet' , function () {
Notification :: fake ();
$user = User :: factory () -> create ();
$order = Order :: factory () -> for ( $user ) -> create ();
$this -> post ( "/orders/{ $order -> id }/ship" );
Notification :: assertSentTo ( $user , OrderShipped :: class , function ( $notification ) use ( $order ) {
return $notification -> order -> id === $order -> id ;
});
});
Tests von Artisan-Commands
use Illuminate\Support\Facades\ Artisan ;
test ( 'Der Cleanup-Command entfernt abgelaufene Daten' , function () {
$expired = Order :: factory () -> create ([ 'expires_at' => now () -> subDay ()]);
$valid = Order :: factory () -> create ([ 'expires_at' => now () -> addDay ()]);
$this -> artisan ( 'orders:cleanup' )
-> assertSuccessful ()
-> expectsOutput ( 'Cleaned up 1 expired order(s).' );
expect ( Order :: find ( $expired -> id )) -> toBeNull ();
expect ( Order :: find ( $valid -> id )) -> not -> toBeNull ();
});
Interaktive Commands testen
test ( 'Interaktiver Command läuft nach Bestätigung' , function () {
$this -> artisan ( 'reports:generate' )
-> expectsQuestion ( 'Ausführen?' , 'yes' )
-> expectsOutput ( 'Report wurde erzeugt.' )
-> assertExitCode ( 0 );
});
RefreshDatabase und LazilyRefreshDatabase
RefreshDatabase
Rollt die Datenbank nach jedem Test zurück und hält sie sauber. Führt beim Start der Suite die Migrationen aus.
// tests/Pest.php
uses ( RefreshDatabase :: class ) -> in ( 'Feature' );
use Illuminate\Foundation\Testing\ RefreshDatabase ;
test ( 'Nutzer kann angelegt werden' , function () {
$user = User :: factory () -> create ([ 'name' => 'Laravel' ]);
expect ( User :: count ()) -> toBe ( 1 );
expect ( $user -> name ) -> toBe ( 'Laravel' );
});
LazilyRefreshDatabase
Während RefreshDatabase für jeden Test Migrationen prüft, wartet LazilyRefreshDatabase bis zum ersten DB-Zugriff. Wenn viele Tests die Datenbank gar nicht anfassen, beschleunigt das die Suite.
// tests/Pest.php
uses ( LazilyRefreshDatabase :: class ) -> in ( 'Feature' );
In den meisten Projekten ist RefreshDatabase die sichere Wahl. LazilyRefreshDatabase lohnt sich, wenn die Suite groß ist und viele Tests ohne DB laufen.
Aspekt RefreshDatabaseLazilyRefreshDatabaseZeitpunkt der Migrationen Zu Beginn der Testsuite Beim ersten DB-Zugriff Overhead in DB-freien Tests ja nein Empfohlener Einsatz Standardfall Große Suite mit vielen DB-freien Tests
Code Coverage messen
Erfordert Xdebug oder PCOV.
# Coverage im Terminal anzeigen
php artisan test --coverage
# Mindestwert erzwingen (unterschritten → CI schlägt fehl)
php artisan test --coverage --min=80
# HTML-Report generieren
vendor/bin/pest --coverage-html=coverage/
# Langsame Tests identifizieren
php artisan test --profile
Coverage-Messung verlängert die Testlaufzeit erheblich. In der CI empfiehlt sich ein eigener Job oder das Ausführen nur bei Pull-Requests.
Verwandte Seiten
Einstieg in Tests Grundlagen des Testens in Laravel und die Nutzung von php artisan test.
Zuletzt geändert am 13. Juli 2026