Retour au cours

backend / php-laravel

Tests avec PHPUnit et Pest

Leçon 201 exercice

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éthodeRôle
RefreshDatabaseRé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
<?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
<?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],
]);
bash
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 Pest

Résumé

  • RefreshDatabase garantit 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

1 disponible
1

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.

Résoudre l’exercice →