Zum Hauptinhalt springen

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:
AspektPestPHPUnit
Testdefinitiontest() / it() mit ClosureKlasse mit Methoden
AssertionsExpectation API (expect()->toBe())$this->assert*()
Datasetsdataset() / with()@dataProvider
HooksbeforeEach() / afterEach()setUp() / tearDown()
NestingGruppierung 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.
AspektRefreshDatabaseLazilyRefreshDatabase
Zeitpunkt der MigrationenZu Beginn der TestsuiteBeim ersten DB-Zugriff
Overhead in DB-freien Testsjanein
Empfohlener EinsatzStandardfallGroß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