backend / php-laravel
Tests avec PHPUnit et Pest
Explication
Ce que vous allez apprendre
- Écrire un test de fonctionnalité (Feature test) qui simule une vraie requête HTTP
- Garantir un état de base de données propre et isolé entre chaque test avec
RefreshDatabase - Vérifier qu'un event a bien été déclenché sans exécuter réellement ses listeners
- Écrire des tests plus concis avec la syntaxe Pest et ses datasets
- Tester systématiquement les cas d'échec, pas seulement le chemin de succès
Dans quel contexte ?
Une équipe a déjà été surprise plusieurs fois par des régressions : une modification du contrôleur de commande a un jour cassé silencieusement la décrémentation du stock, découverte seulement en production. Une suite de tests automatisés permet de détecter ce genre de régression immédiatement, avant même que le code ne soit déployé.
D'abord, tester à travers une vraie requête HTTP simulée
Un Feature test comme test_creation_produit_valide simule une requête complète : authentification, envoi de données JSON, et vérification de la réponse ET de l'état final en base de données. $this->postJson(...) et $this->actingAs($user, 'sanctum') reproduisent fidèlement ce qui se passerait avec un vrai client API, sans jamais démarrer de vrai serveur.
assertDatabaseHas('produits', ['sku' => 'KB-001']) vérifie que la donnée a réellement été persistée, et pas seulement que la réponse HTTP semblait correcte — une nuance importante pour détecter un bug qui validerait sans réellement sauvegarder.
Prérequis
Cette leçon suppose une bonne connaissance des contrôleurs, de l'authentification Sanctum et des events (leçons 9, 16, 19), puisque les tests couvrent typiquement ces trois aspects ensemble.
Ensuite, un état de base de données garanti propre à chaque test
Sans précaution, un test pourrait laisser des données qui influencent le test suivant, rendant les résultats imprévisibles selon l'ordre d'exécution. RefreshDatabase recrée la base entre chaque test, garantissant que chaque test démarre dans un état totalement maîtrisé et reproductible.
| Trait / méthode | Rôle |
|---|---|
RefreshDatabase | Réinitialise la base entre chaque test |
actingAs($user, 'sanctum') | Simule un utilisateur authentifié via ce guard |
assertDatabaseHas(...) | Vérifie qu'une ligne existe réellement en base |
Event::fake() | Intercepte les events sans exécuter leurs listeners |
Piège fréquent
Oublier RefreshDatabase sur une classe de test peut faire "passer" des tests uniquement parce que des données laissées par un test précédent traînent encore en base — un faux sentiment de sécurité qui s'effondre dès que l'ordre d'exécution des tests change.
Il reste à isoler ce qu'on veut vraiment tester
Tester CommandeService::valider() ne devrait pas nécessiter qu'un vrai email parte réellement, ni qu'un vrai listener s'exécute. Event::fake() intercepte les events déclenchés sans exécuter leurs listeners, permettant de vérifier UNIQUEMENT que le bon event a été déclenché avec les bonnes données (Event::assertDispatched(...)), indépendamment de ce que les listeners en feraient.
Maintenant, une syntaxe alternative plus concise : Pest
Pest reformule les mêmes concepts que PHPUnit avec une syntaxe fonctionnelle plus courte (test('...', function () { ... })), tout en restant compatible avec les mêmes assertions et traits. Les datasets Pest (->with([...])) exécutent automatiquement le même test avec plusieurs jeux de données, évitant de dupliquer un test presque identique pour chaque cas.
Astuce
Teste systématiquement les cas d'échec (test_creation_produit_requiert_authentification, test_validation_rejette_prix_negatif) et pas seulement le chemin heureux : ce sont souvent les cas d'erreur, moins visibles au premier coup d'œil, qui révèlent les vraies failles de sécurité ou de logique métier.
Maintenant que le code est testé de façon fiable, la prochaine leçon aborde un sujet complémentaire : comment le rendre rapide en production grâce au cache et à d'autres techniques d'optimisation.
Commandes & code
Tests avec PHPUnit et Pest
<?php
// tests/Feature/ProduitApiTest.php : style PHPUnit classique
namespace Tests\Feature;
use App\Models\{Produit, Categorie, User};
use Illuminate\Foundation\Testing\RefreshDatabase; // recrée la base entre chaque test
use Tests\TestCase;
class ProduitApiTest extends TestCase {
use RefreshDatabase;
public function test_liste_les_produits_disponibles(): void {
Produit::factory(3)->create(['est_disponible' => true]);
Produit::factory(2)->create(['est_disponible' => false]);
$reponse = $this->getJson('/api/v1/produits?disponible=1');
$reponse->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure(['data' => [['id', 'nom', 'prix']], 'meta']);
}
public function test_creation_produit_requiert_authentification(): void {
$reponse = $this->postJson('/api/v1/produits', ['nom' => 'Test']);
$reponse->assertUnauthorized();
}
public function test_creation_produit_valide(): void {
$user = User::factory()->admin()->create();
$categorie = Categorie::factory()->create();
$reponse = $this->actingAs($user, 'sanctum')->postJson('/api/v1/produits', [
'nom' => 'Clavier mécanique',
'sku' => 'KB-001',
'prix' => 89.90,
'categorie_id' => $categorie->id,
]);
$reponse->assertCreated()->assertJsonPath('nom', 'Clavier mécanique');
$this->assertDatabaseHas('produits', ['sku' => 'KB-001']);
}
public function test_validation_rejette_prix_negatif(): void {
$user = User::factory()->admin()->create();
$reponse = $this->actingAs($user, 'sanctum')->postJson('/api/v1/produits', [
'nom' => 'Test', 'sku' => 'X', 'prix' => -10, 'categorie_id' => 1,
]);
$reponse->assertUnprocessable()->assertJsonValidationErrors(['prix']);
}
}<?php
// tests/Feature/CommandeTest.php : style Pest, plus concis et expressif
use App\Models\{Commande, Produit};
use App\Events\CommandeValidee;
use Illuminate\Support\Facades\Event;
test('la validation d\'une commande décrémente le stock', function () {
$produit = Produit::factory()->create(['stock' => 10]);
$commande = Commande::factory()
->has(\App\Models\Client::factory())
->create();
$commande->produits()->attach($produit, ['quantite' => 3, 'prix_unitaire' => 10]);
app(\App\Services\CommandeService::class)->valider($commande);
expect($produit->fresh()->stock)->toBe(7);
});
test('la validation déclenche l\'event CommandeValidee', function () {
Event::fake(); // intercepte les events sans exécuter les listeners
$commande = Commande::factory()->create();
app(\App\Services\CommandeService::class)->valider($commande);
Event::assertDispatched(CommandeValidee::class, fn ($e) => $e->commande->is($commande));
});
// Dataset Pest : exécute le même test avec plusieurs jeux de données
test('valide le format des SKU', function (string $sku, bool $attendu) {
expect(preg_match('/^[A-Z]{2,4}-\d{3,6}$/', $sku))->toBe($attendu ? 1 : 0);
})->with([
['ABC-123', true],
['ab-123', false],
['ABCDE-123', false],
]);php artisan test # lance toute la suite
php artisan test --filter=ProduitApiTest # cible une classe de test
php artisan test --coverage # couverture (nécessite Xdebug/PCOV)
./vendor/bin/pest --parallel # exécution parallèle avec PestRésumé
RefreshDatabasegarantit un état de base propre et isolé entre chaque test.Event::fake()isole le test de la logique des listeners pour ne tester que le déclenchement.- Les datasets Pest (
->with([...])) évitent de dupliquer un test pour chaque cas. - Toujours tester les cas d'échec (validation, autorisation) et pas seulement le chemin heureux.
Exercices pratiques
Mission : un test vert qui cache un vrai bug
Objectif : Identifier pourquoi une suite de tests entierement verte n'a pas detecte une regression, puis ecrire les tests qui l'auraient attrapee.
Contexte
Toute la suite php artisan test passe au vert, pourtant un bug a atteint la production : CommandeService::valider() decremente desormais le stock de la mauvaise quantite lorsqu'un produit apparait plusieurs fois dans la meme commande. En regardant CommandeTest, tu remarques que le test Pest existant ne verifie que le cas d'un produit unique en quantite 3, et qu'aucun test de la classe ProduitApiTest n'utilise RefreshDatabase, ce qui a laisse des donnees residuelles masquer certains echecs lors des executions precedentes.