Pest-Testdisziplin
Funktionen, die ungetestet ausgeliefert werden – oder "getestet" mit übermocked Suiten, die Implementierungsdetails behaupten – brechen in der Produktion still und verrotten bei jeder Refaktorierung. Diese Fähigkeit zwingt Sie dazu, Pest 3/4-Tests zu schreiben, die Verhalten beweisen, den unglücklichen Pfad und die Autorisierung abdecken und Refaktorierungen überstehen.
Die Gefahrenquelle
Zwei Fehlermodi, beide teuer:
- Keine Tests / Nur-Happy-Path-Tests. Die Funktion funktioniert in der Demo, aber dann erreicht in der Produktion ein Gast den Endpunkt, ein Mandant liest die Daten eines anderen Mandanten, eine Validierungslücke lässt Müll herein oder ein 404 wird zu einem 500. Autorisierungslücken (
403/404-Pfade, die nie behauptet wurden) sind der Grund, warum Datenlecks in die Produktion gelangen. - Spröde, übermocked Tests. Sie mocken Ihr eigenes
UserRepository/Ihren Service mitshouldReceive('save')->once(), also besteht der Test, selbst wenn die echte Abfrage falsch ist, und explodiert, sobald jemand eine Methode umbenennt. Die Suite ist grün, gibt falsche Sicherheit und wird bei der nächsten Refaktorierung frustriert gelöscht – zurück zu Modus 1.
Die Lösung ist dieselbe Disziplin in beiden Fällen: Faken Sie die Grenzen, die Sie nicht besitzen, führen Sie den Code aus, den Sie besitzen, und behaupten Sie beobachtbare Ergebnisse (DB-Zeilen, HTTP-Status, ausgelöste Jobs/Mails) – einschließlich der Fehler- und Autorisierungsergebnisse.
Regeln
- Schreiben Sie den Test mit der Funktion, nicht "später". Eine Controller/Job/Befehl/Richtlinienänderung ist erst abgeschlossen, wenn sie einen Pest-Test hat. Melden Sie eine Funktion niemals als erledigt ohne einen entsprechenden Test, der ihr Verhalten behauptet.
- Verwenden Sie Pest-Idiome. Definieren Sie Fälle mit
it('...')odertest('...'); behaupten Sie mit dem fließendenexpect(...); teilen Sie die Einrichtung mitbeforeEach(). Schreiben Sie kein PHPUnitclass FooTest extends TestCase, es sei denn, die Datei verwendet bereits diesen Stil. - Wählen Sie den richtigen Typ. HTTP/End-to-End-Verhalten → Feature-Test (startet das Framework, trifft Routen via
$this->get/post/...). Reine Logik ohne Framework/DB → Unit-Test. Standardmäßig einen Feature-Test wählen, wenn Sie unsicher sind – er fängt mehr ab. - Setzen Sie die Datenbank mit einem Trait zurück, via
uses(). Wenden SieRefreshDatabase(transaktionsgekapselt) oderLazilyRefreshDatabase(migriert nur, wenn ein Test die DB berührt) intests/Pest.phpfür das gesamteFeature-Verzeichnis an, oderuses(RefreshDatabase::class)am Anfang einer Datei. Lassen Sie DB-Tests NIEMALS von übriggebliebenem Zustand abhängen. - Bauen Sie Daten mit Fabriken, niemals handgerollten Inserts. Verwenden Sie
User::factory()->create()/->make()und Beziehungsfabriken. HandgebauteDB::table()->insert([...])odernew Model([...])->save()in Tests ist spröde und überspringt Casts/Defaults – verbieten Sie es. - Entdeduplizieren Sie mit Datensätzen, nicht mit Copy-Paste. Wenn dieselbe Behauptung über viele Eingaben läuft (gültige/ungültige Payloads, Rollenmatrizen), verwenden Sie
->with([...])(inline oder einen benannten Datensatz intests/Datasets/) anstatt N nahezu identischer Tests. - Faken Sie die Grenzen, die Sie nicht besitzen. Verwenden Sie für alles Externe oder Asynchrone die Fassaden-Fakes vor der Aktion:
Http::fake(),Queue::fake(),Bus::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake(). Für Zeit verwenden Sie$this->travel(...),$this->travelTo(...)oder$this->freezeTime()– behaupten Sie niemals gegen ein nicht eingefrorenesnow(). - Mocken Sie NICHT Ihre eigenen Modelle/Services in Bedeutungslosigkeit. Vermeiden Sie
Mockery::mock(MyService::class)->shouldReceive(...)undModel::shouldReceive(...)für zu testenden Code. Lassen Sie den echten Code gegen die Test-DB und die gefakten Grenzen laufen. Mocken Sie Ihre eigene Klasse nur an einer echten Nahtstelle, die Sie nicht ausführen (z. B. einen langsamen Drittanbieter-SDK-Wrapper). - Behaupten Sie Ergebnisse, nicht Interna. Bevorzugen Sie
assertDatabaseHas/assertDatabaseMissing/assertModelExists,assertStatus/assertOk/assertCreated,assertJson/assertJsonPath,assertRedirect,Mail::assertSent,Notification::assertSentTo,Queue::assertPushed,Bus::assertDispatched,Storage::disk()->assertExists. Behaupten Sie nicht "Methode X wurde einmal aufgerufen" als Proxy für Verhalten. - Testen Sie IMMER den unglücklichen Pfad UND die Autorisierung. Jeder Endpunkt/Aktion benötigt mindestens: den glücklichen Pfad, einen Validierungs-/Fehlerpfad und die Auth-Pfade – Gast →
401/Weiterleitung, falscher Benutzer/Rolle →403, fehlende/fremde Ressource →404. Fügen Sie für jede sicherheitskritische Funktion einen expliziten "Ein anderer Benutzer kann nicht darauf zugreifen/dies ändern"-Test hinzu. Dies ist nicht verhandelbar für alles, was Eigentum oder Mandantenfähigkeit betrifft. - Fügen Sie Architekturtests als billige, globale Schutzgeländer hinzu. Halten Sie eine
tests/Arch.php(oder Arch-Fälle inPest.php) mit den Voreinstellungen, die passen:arch()->preset()->php()(verbietetdie/var_dump/dd-artige Debug-Ausgaben und veraltete PHP-Funktionen),arch()->preset()->security()(verbieteteval,extract,unserialize, schwaches Hashing/md5/sha1, unsichere Zufälligkeit usw.) undarch()->preset()->laravel()(erzwingt Laravel-Konventionen – einschließlich keinenv()außerhalb der Konfiguration). Fügen Sie Projektinvarianten hinzu wiearch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller'). - Ein Verhalten pro Test, beschreibender Name.
it('blockiert Gäste vom Dashboard'), nichtit('funktioniert'). Wenn ein Testname "und" benötigt, teilen Sie ihn auf. - Erzwingen Sie Abdeckung in CI. Führen Sie
./vendor/bin/pest --coverage --min=NNaus, um ein Minimum zu erzwingen, und./vendor/bin/pest --type-coverage(erfordert das Type-Coverage-Plugin), um typisierte Signaturen zu erzwingen. Behandeln Sie neue ungetestete Zeilen als Fehler. - Browser/E2E sparsam (Pest 4). Pest 4 liefert Browser-Tests via
visit('/')->...->assertSee(...)(Playwright-gestützt). Reservieren Sie es für echte Full-Stack/JS-Flows; führen Sie HTTP-Layer-Assertions nicht als langsame Browser-Tests neu ein.
Gut vs. schlecht
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// ❌ übermocked: testet, dass eine Methode aufgerufen wurde, nicht was passiert ist.
// Besteht, selbst wenn der falsche Post aktualisiert wird oder die Auth fehlt.
it('aktualisiert einen Beitrag', function () {
$service = Mockery::mock(App\Services\PostService::class);
$service->shouldReceive('update')->once()->andReturnTrue();
$this->app->instance(App\Services\PostService::class, $service);
$this->put('/posts/1', ['title' => 'Neu'])->assertOk();
});
// ✅ führt echten Code gegen die Test-DB aus; behauptet das Ergebnis UND die Autorisierung.
use App\Models\Post;
use App\Models\User;
it('erlaubt dem Besitzer, seinen Beitrag zu aktualisieren', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => 'Alt']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => 'Neu'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Neu']);
});
it('verbietet einem Nicht-Besitzer, den Beitrag zu aktualisieren', function () {
$post = Post::factory()->create(['title' => 'Alt']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => 'Gehackt'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Alt']);
});
it('blockiert Gäste', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => 'Neu'])
->assertRedirect('/login'); // oder assertUnauthorized() für eine API
});
// ❌ trifft das echte Netzwerk (flakelig, langsam, kann Schlüssel preisgeben); behauptet nichts Nützliches.
it('benachrichtigt Slack', function () {
$resp = (new App\Services\Slack)->ping('Deploy ok');
expect($resp)->toBeArray();
});
// ✅ fake die Grenze, die Sie nicht besitzen; behaupten Sie die gesendete Anfrage.
use Illuminate\Support\Facades\Http;
it('postet die Deploy-Nachricht an Slack', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('Deploy ok');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === 'Deploy ok'
);
});
// ❌ kopierte nahezu identische Validierungstests.
it('lehnt leere E-Mail ab', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('lehnt schlechte E-Mail ab', function () {
$this->post('/register', ['email' => 'nein'])->assertSessionHasErrors('email');
});
// ✅ ein Test, tabellengetrieben mit einem Datensatz.
it('lehnt ungültige E-Mails ab', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'leer' => '',
'kein Klammeraffe' => 'nein',
'keine Domain' => 'a@',
]);
// ✅ asynchrone + Zeitgrenzen gefaked; behaupten Sie den Dispatch und den Nebeneffekt.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('stellt eine Erinnerung in die Warteschlange und begrüßt den Benutzer bei der Registrierung', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'geheimes-passwort',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php — ✅ billige globale Schutzgeländer
arch()->preset()->php(); // verbietet die/var_dump/dd-artige Debug-Ausgabe + veraltete PHP-Funktionen
arch()->preset()->security(); // verbietet eval, extract, unserialize, md5/sha1, schwache Zufälligkeit usw.
arch()->preset()->laravel(); // erzwingt Laravel-Konventionen, inkl. kein env() außerhalb der Konfiguration
arch('Controller erweitern den Basis-Controller')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('Modelle leben im richtigen Namensraum')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
So überprüfen Sie
Führen Sie diese aus, bevor Sie eine Funktion und ihre Tests als erledigt melden:
# 1. Die gesamte Suite muss grün sein.
./vendor/bin/pest
# 2. Führen Sie nur die neue(n)/geänderte(n) Datei(en) während der Iteration aus.
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. Erzwingen Sie eine Abdeckungsuntergrenze (erfordert Xdebug oder PCOV).
./vendor/bin/pest --coverage --min=80
# 4. Typabdeckung (erfordert pestphp/pest-plugin-type-coverage).
./vendor/bin/pest --type-coverage --min=100
# 5. Stil bleibt sauber.
./vendor/bin/pint --test
Prüfen Sie dann, dass Sie die Gefahrenquelle NICHT wieder eingeführt haben:
# Authz-Abdeckung: jeder geschützte Feature-Test sollte einen 401/403/404-Pfad behaupten.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/
# Over-Mocking-Geruch: Das Mocken Ihrer EIGENEN App-Klassen ist eine rote Flagge – überprüfen Sie jeden Treffer.
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\\(" tests/
# Handgerollte Inserts anstelle von Fabriken – ersetzen Sie durch ->factory().
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/
# Externe Grenzen müssen gefaked werden – bestätigen Sie, dass Fakes vorhanden sind, wo Code nach außen ruft.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# Die Security + PHP Arch-Voreinstellungen (die billigen globalen Schutzgeländer) müssen irgendwo vorhanden sein.
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/
Behaupten Sie in Tests, nicht nur im Code: bestätigen Sie für jeden neuen Endpunkt, dass (a) eine Happy-Path-Ergebnisbehauptung (assertDatabaseHas/assertOk/assertJsonPath), (b) mindestens eine Fehler-/Validierungsbehauptung und (c) die Gast- + Falscher-Benutzer-Autorisierungsbehauptungen vorhanden sind. Wenn etwas fehlt, ist die Funktion nicht erledigt.
Wann es in Ordnung ist, die Regel zu biegen
- Mocken Ihres eigenen Codes an einer echten externen Nahtstelle. Ein dünner Wrapper um ein kostenpflichtiges Drittanbieter-SDK (SMS, Zahlungsgateway), den Sie nicht auf HTTP-Ebene faken können, ist ein legitimes
Mockery-Ziel – Sie isolieren deren Abhängigkeit, nicht das zu testende Verhalten. - Unit-Testen reiner Logik ohne DB. Wertobjekte, Formatierer und Rechner benötigen kein
RefreshDatabaseund keine Fabriken – instanziieren Sie sie direkt undexpect()das Ergebnis; das ist schneller und korrekt. make()übercreate()wenn ein Test nie persistiert –User::factory()->make()vermeidet einen DB-Schreibvorgang für reine Logikbehauptungen.- Niedrigere Abdeckungsschwellen für Legacy-Code. Setzen Sie ein realistisches
--minund erhöhen Sie es im Laufe der Zeit, anstatt die gesamte Arbeit zu blockieren; lassen Sie niemals zu, dass "100 % oder nichts" eine Ausrede wird, um null Tests auszuliefern. - Browser-Tests für echt JS-gesteuerte Flows (Livewire/Inertia/Vue-Interaktionen, Dateiauswähler) – dort ist
visit()das richtige Werkzeug, kein Overreach.
Referenzen
- Pest — Tests schreiben, Erwartungen, Hooks: https://pestphp.com/docs/writing-tests
- Pest — Datensätze: https://pestphp.com/docs/datasets
- Pest — Architektur-Tests & Voreinstellungen (
security,laravel,toExtend): https://pestphp.com/docs/arch-testing - Pest — Abdeckung &
--min: https://pestphp.com/docs/coverage - Pest — Typabdeckungs-Plugin: https://pestphp.com/docs/type-coverage
- Pest v4 — Browser-Tests (
visit(), visuelle Regression): https://pestphp.com/docs/browser-testing - Laravel — HTTP-Tests (Status/JSON/Weiterleitungs-Assertions): https://laravel.com/docs/testing
- Laravel — Datenbank-Tests (
RefreshDatabase, Fabriken,assertDatabaseHas): https://laravel.com/docs/database-testing - Laravel — Mocking & Fassaden-Fakes (
Http,Queue,Bus,Mail,Notification,Event,Storage, Zeit): https://laravel.com/docs/mocking