Discipline de test Pest
Les fonctionnalitĂ©s livrĂ©es non testĂ©es â ou « testĂ©es » avec des suites trop mockĂ©es qui vĂ©rifient les dĂ©tails d'implĂ©mentation â plantent silencieusement en production et pourrissent Ă chaque refactorisation. Cette compĂ©tence vous fait Ă©crire des tests Pest 3/4 qui prouvent le comportement, couvrent les chemins d'Ă©chec et d'autorisation, et survivent aux refactorisations.
Le piĂšge
Deux modes de défaillance, tous deux coûteux :
- Pas de tests / tests uniquement positifs. La fonctionnalité fonctionne dans la démo, puis en production un invité accÚde au point de terminaison, un locataire lit les données d'un autre locataire, une lacune de validation laisse entrer des déchets, ou une 404 devient une 500. Les failles d'autorisation (chemins
403/404jamais vérifiés) sont ainsi que les fuites de données arrivent en production. - Tests fragiles et trop mockés. Vous mockez votre propre
UserRepository/service avecshouldReceive('save')->once(), donc le test passe mĂȘme si la vraie requĂȘte est erronĂ©e, et il explose dĂšs que quelqu'un renomme une mĂ©thode. La suite est verte, donne une fausse confiance, et est supprimĂ©e par frustration lors de la prochaine refactorisation â vous laissant revenir au mode 1.
La solution est la mĂȘme discipline dans les deux cas : fausser les frontiĂšres que vous ne possĂ©dez pas, exĂ©cuter le code que vous possĂ©dez, et vĂ©rifier les rĂ©sultats observables (lignes DB, statut HTTP, jobs/mails dispatchĂ©s) â y compris les rĂ©sultats d'Ă©chec et d'autorisation.
RĂšgles
- Ăcrire le test avec la fonctionnalitĂ©, pas « plus tard ». Un changement de contrĂŽleur/job/commande/politique est incomplet tant qu'il n'a pas un test Pest. Ne signalez jamais une fonctionnalitĂ© comme terminĂ©e sans un test correspondant vĂ©rifiant son comportement.
- Utiliser les idiomes Pest. Définissez les cas avec
it('...')outest('...'); vĂ©rifiez avec le fluideexpect(...); partagez la configuration avecbeforeEach(). N'Ă©crivez pasclass FooTest extends TestCaseĂ la PHPUnit sauf si le fichier utilise dĂ©jĂ ce style. - Choisissez le bon type. Comportement HTTP/end-to-end â Test de fonctionnalitĂ© (dĂ©marre le framework, accĂšde aux routes via
$this->get/post/...). Logique pure sans framework/DB â Test unitaire. Par dĂ©faut, choisissez un test de fonctionnalitĂ© en cas de doute â il attrape plus de problĂšmes. - RĂ©initialisez la base de donnĂ©es avec un trait, via
uses(). AppliquezRefreshDatabase(enveloppé dans une transaction) ouLazilyRefreshDatabase(migre uniquement lorsqu'un test touche la DB) danstests/Pest.phppour tout le répertoireFeature, ouuses(RefreshDatabase::class)en haut d'un fichier. NE laissez JAMAIS les tests DB dépendre d'un état résiduel. - Construisez les données avec des usines, jamais des insertions à la main. Utilisez
User::factory()->create()/->make()et les usines de relations. LesDB::table()->insert([...])ounew Model([...])->save()faits Ă la main dans les tests sont fragiles et ignorent les casts/valeurs par dĂ©faut â bannissez-les. - DĂ©doublez avec des jeux de donnĂ©es, pas du copier-coller. Lorsque la mĂȘme vĂ©rification s'exĂ©cute sur plusieurs entrĂ©es (charges utiles valides/invalides, matrices de rĂŽles), utilisez
->with([...])(inline ou un jeu de données nommé danstests/Datasets/) au lieu de N tests quasi identiques. - Faussez les frontiÚres que vous ne possédez pas. Pour tout ce qui est externe ou asynchrone, utilisez les fausses façade avant l'action :
Http::fake(),Queue::fake(),Bus::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake(). Pour le temps, utilisez$this->travel(...),$this->travelTo(...), ou$this->freezeTime()â ne vĂ©rifiez jamais contre unnow()non figĂ©. - NE mockez PAS vos propres modĂšles/services jusqu'Ă les rendre insignifiants. Ăvitez
Mockery::mock(MyService::class)->shouldReceive(...)etModel::shouldReceive(...)pour le code testé. Laissez le vrai code s'exécuter contre la DB de test et les frontiÚres fausses. Mockez votre propre classe uniquement à une véritable couture que vous n'exercez pas (par exemple, un wrapper SDK tiers lent). - Vérifiez les résultats, pas les internes. Préférez
assertDatabaseHas/assertDatabaseMissing/assertModelExists,assertStatus/assertOk/assertCreated,assertJson/assertJsonPath,assertRedirect,Mail::assertSent,Notification::assertSentTo,Queue::assertPushed,Bus::assertDispatched,Storage::disk()->assertExists. Ne vĂ©rifiez pas « la mĂ©thode X a Ă©tĂ© appelĂ©e une fois » comme proxy du comportement. - Testez toujours le chemin d'Ă©chec ET l'autorisation. Chaque point de terminaison/action nĂ©cessite, au minimum : le chemin heureux, un chemin de validation/Ă©chec, et les chemins d'auth â invitĂ© â
401/redirection, mauvais utilisateur/rĂŽle â403, ressource manquante/Ă©trangĂšre â404. Pour toute fonctionnalitĂ© sensible Ă la sĂ©curitĂ©, ajoutez un test explicite « un autre utilisateur ne peut pas accĂ©der/modifier cela ». C'est non-nĂ©gociable pour tout ce qui touche Ă la propriĂ©tĂ© ou Ă la location. - Ajoutez des tests d'architecture comme garde-fous globaux Ă faible coĂ»t. Gardez un fichier
tests/Arch.php(ou des cas d'arch dansPest.php) avec les prĂ©rĂ©glages qui conviennent :arch()->preset()->php()(bannit la sortie de dĂ©bogagedie/var_dump/ddet les fonctions PHP obsolĂštes),arch()->preset()->security()(banniteval,extract,unserialize, le hachage faible/md5/sha1, l'alĂ©atoire non sĂ©curisĂ©, etc.), etarch()->preset()->laravel()(impose les conventions Laravel â y compris pas deenv()en dehors de config). Ajoutez des invariants de projet commearch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller'). - Un comportement par test, nom descriptif.
it('bloque les invités du tableau de bord'), pasit('fonctionne'). Si un nom de test a besoin d'un « et », divisez-le. - Couverture de porte dans CI. Exécutez
./vendor/bin/pest --coverage --min=NNpour imposer un minimum, et./vendor/bin/pest --type-coverage(nécessite le plugin type-coverage) pour imposer des signatures typées. Traitez les nouvelles lignes non testées comme un défaut. - Browser/E2E avec parcimonie (Pest 4). Pest 4 propose des tests navigateur via
visit('/')->...->assertSee(...)(basé sur Playwright). Réservez-le pour les flux full-stack/JS véritables ; ne réimplémentez pas les vérifications de couche HTTP comme des tests navigateur lents.
Bon vs mauvais
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// â trop mockĂ© : teste qu'une mĂ©thode a Ă©tĂ© appelĂ©e, pas ce qui s'est passĂ©.
// Passe mĂȘme si le mauvais article est mis Ă jour ou si l'auth est absente.
it('met Ă jour un article', 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' => 'Nouveau'])->assertOk();
});
// â
exécute le vrai code contre la DB de test ; vérifie le résultat ET l'authz.
use App\Models\Post;
use App\Models\User;
it('laisse le propriétaire mettre à jour son article', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => 'Ancien']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => 'Nouveau'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Nouveau']);
});
it('interdit à un non-propriétaire de mettre à jour l\'article', function () {
$post = Post::factory()->create(['title' => 'Ancien']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => 'Piraté'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Ancien']);
});
it('bloque les invités', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => 'Nouveau'])
->assertRedirect('/login'); // ou assertUnauthorized() pour une API
});
// â touche le vrai rĂ©seau (instable, lent, peut fuiter des clĂ©s) ; ne vĂ©rifie rien d'utile.
it('notifie slack', function () {
$resp = (new App\Services\Slack)->ping('déploiement ok');
expect($resp)->toBeArray();
});
// â
fausse la frontiĂšre que vous ne possĂ©dez pas ; vĂ©rifie la requĂȘte envoyĂ©e.
use Illuminate\Support\Facades\Http;
it('envoie le message de déploiement à slack', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('déploiement ok');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === 'déploiement ok'
);
});
// â tests de validation quasi identiques copiĂ©s-collĂ©s.
it('rejette un email vide', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rejette un mauvais email', function () {
$this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// â
un seul test, piloté par un jeu de données.
it('rejette les emails invalides', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'vide' => '',
'pas d@' => 'nope',
'pas de domaine' => 'a@',
]);
// â
frontiÚres asynchrones + temporelles fausses ; vérifie le dispatch et l'effet secondaire.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('met en file d\'attente un rappel et accueille l\'utilisateur lors de l\'inscription', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'mot-de-passe-secret',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php â â
garde-fous globaux à faible coût
arch()->preset()->php(); // bannit la sortie de débogage die/var_dump/dd + fonctions PHP obsolÚtes
arch()->preset()->security(); // bannit eval, extract, unserialize, md5/sha1, aléatoire faible, etc.
arch()->preset()->laravel(); // impose les conventions Laravel, incl. pas de env() en dehors de config
arch('les contrÎleurs étendent le contrÎleur de base')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('les modĂšles vivent dans le bon espace de noms')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
Comment vérifier
Exécutez ces commandes avant de déclarer une fonctionnalité + ses tests comme terminés :
# 1. Toute la suite doit ĂȘtre verte.
./vendor/bin/pest
# 2. Exécutez uniquement le(s) nouveau(x)/modifié(s) fichier(s) pendant l'itération.
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. Imposez un seuil de couverture (nécessite Xdebug ou PCOV).
./vendor/bin/pest --coverage --min=80
# 4. Couverture des types (nécessite pestphp/pest-plugin-type-coverage).
./vendor/bin/pest --type-coverage --min=100
# 5. Le style reste propre.
./vendor/bin/pint --test
Ensuite, auditez que vous N'AVEZ PAS réintroduit le piÚge :
# Couverture d'authz : chaque test de fonctionnalité protégé doit vérifier un chemin 401/403/404.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/
# Odeur de sur-mockage : mocker vos propres classes d'application est un drapeau rouge â examinez chaque occurrence.
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/
# Insertions Ă la main au lieu d'usines â remplacez par ->factory().
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/
# Les frontiĂšres externes doivent ĂȘtre fausses â confirmez que les fausses existent lĂ oĂč le code appelle de l'extĂ©rieur.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# Les prĂ©rĂ©glages d'arch sĂ©curitĂ© + php (les garde-fous globaux Ă faible coĂ»t) doivent ĂȘtre prĂ©sents quelque part.
grep -rEn "preset\(\\)->(security|php|laravel)\(\\)" tests/
Vérifiez dans les tests, pas seulement dans le code : pour chaque nouveau point de terminaison, confirmez qu'il y a (a) une vérification de résultat du chemin heureux (assertDatabaseHas/assertOk/assertJsonPath), (b) au moins une vérification d'échec/validation, et (c) les vérifications d'autorisation invité + mauvais utilisateur. Si l'un manque, la fonctionnalité n'est pas terminée.
Quand il est acceptable de déroger à la rÚgle
- Mocker votre propre code à une véritable couture externe. Un wrapper mince autour d'un SDK tiers payant (SMS, passerelle de paiement) que vous ne pouvez pas fausser au niveau HTTP est une cible légitime pour
Mockeryâ vous isolez leur dĂ©pendance, vous ne faussez pas le comportement testĂ©. - Tester unitairement une logique pure sans base de donnĂ©es. Les objets de valeur, formatteurs et calculateurs n'ont besoin ni de
RefreshDatabaseni d'usines â instanciez-les directement et utilisezexpect()pour le rĂ©sultat ; c'est plus rapide et correct. make()plutĂŽt quecreate()lorsqu'un test ne persiste jamais âUser::factory()->make()Ă©vite une Ă©criture DB pour les vĂ©rifications de logique pure.- Seuils de couverture plus bas sur le code hĂ©ritĂ©. Fixez un
--minrĂ©aliste et augmentez-le progressivement plutĂŽt que de bloquer tout travail ; ne laissez jamais « 100% ou rien » devenir une excuse pour livrer zĂ©ro test. - Tests navigateur pour les flux vraiment pilotĂ©s par JS (interactions Livewire/Inertia/Vue, sĂ©lecteurs de fichiers) â lĂ ,
visit()est le bon outil, pas une exagération.
Références
- Pest â Ăcrire des tests, expectations, hooks : https://pestphp.com/docs/writing-tests
- Pest â Jeux de donnĂ©es : https://pestphp.com/docs/datasets
- Pest â Tests d'architecture & prĂ©rĂ©glages (
security,laravel,toExtend) : https://pestphp.com/docs/arch-testing - Pest â Couverture &
--min: https://pestphp.com/docs/coverage - Pest â Plugin de couverture des types : https://pestphp.com/docs/type-coverage
- Pest v4 â Tests navigateur (
visit(), rĂ©gression visuelle) : https://pestphp.com/docs/browser-testing - Laravel â Tests HTTP (vĂ©rifications de statut/JSON/redirection) : https://laravel.com/docs/testing
- Laravel â Tests de base de donnĂ©es (
RefreshDatabase, usines,assertDatabaseHas) : https://laravel.com/docs/database-testing - Laravel â Mocking & fausses façade (
Http,Queue,Bus,Mail,Notification,Event,Storage, temps) : https://laravel.com/docs/mocking