Tests Laravel avec TDD
Développement piloté par les tests pour les applications Laravel à l'aide de PHPUnit, Pest, Laravel factories et des helpers de test.
Quand activer
- Écrire de nouvelles applications ou fonctionnalités Laravel
- Implémenter des points d'accès API avec l'authentification Sanctum ou Passport
- Tester les modèles Eloquent, les relations, les scopes et les accesseurs
- Mettre en place l'infrastructure de test pour les projets Laravel
- Écrire des tests fonctionnels pour les contrôleurs HTTP et les requêtes de formulaire
- Simuler des services externes (files d'attente, courrier, notifications, HTTP)
Workflow TDD pour Laravel
Cycle Rouge-Vert-Réusinage
// Étape 1 : ROUGE — Écrire un test qui échoue
public function test_un_produit_peut_etre_cree(): void
{
$produit = Product::factory()->create(['nom' => 'Produit test']);
$this->assertDatabaseHas('products', ['nom' => 'Produit test']);
}
// Étape 2 : VERT — Écrire la migration, le modèle et la factory
// Étape 3 : RÉUSINAGE — Améliorer tout en gardant les tests verts
Configuration
Configuration PHPUnit
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unité">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Fonctionnel">
<directory suffix="Test.php">tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>
Configuration de la classe TestCase de base
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// Appeler $this->withoutExceptionHandling() uniquement dans les tests qui
// testent des exceptions non-HTTP ; cela supprime assertStatus() etc.
}
// Helper : Authentifier et retourner un utilisateur
protected function actingAsUser(): mixed
{
$user = \App\Models\User::factory()->create();
$this->actingAs($user);
return $user;
}
protected function actingAsAdmin(): mixed
{
$admin = \App\Models\User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
}
Model Factories
// database/factories/UserFactory.php
class UserFactory extends Factory
{
protected static ?string $password = null;
public function definition(): array
{
return [
'nom' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'mot_de_passe' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'role' => 'utilisateur',
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => ['role' => 'admin']);
}
public function non_verifie(): static
{
return $this->state(fn (array $attributes) => ['email_verified_at' => null]);
}
}
// database/factories/ProductFactory.php
class ProductFactory extends Factory
{
public function definition(): array
{
return [
'nom' => fake()->unique()->words(3, true),
'slug' => fn (array $attrs) => Str::slug($attrs['nom']),
'description' => fake()->paragraph(),
'prix' => fake()->numberBetween(100, 100000),
'stock' => fake()->numberBetween(0, 100),
'actif' => true,
'id_utilisateur' => UserFactory::new(),
];
}
public function hors_stock(): static
{
return $this->state(fn (array $attributes) => ['stock' => 0]);
}
}
Utilisation des Factories
$user = User::factory()->create();
$admin = User::factory()->admin()->create();
$produit = Product::factory()->create(['id_utilisateur' => $user->id]);
$produits = Product::factory()->count(10)->create();
$brouillon = Product::factory()->make(); // Non persisté
// Avec relations
$user = User::factory()->has(Product::factory()->count(3))->create();
// Séquences
User::factory()->count(3)->sequence(
['role' => 'admin'], ['role' => 'éditeur'], ['role' => 'utilisateur'],
)->create();
Tests de modèle
namespace Tests\Unit\Models;
use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function test_il_cache_les_attributs_sensibles(): void
{
$user = User::factory()->create();
$this->assertArrayNotHasKey('password', $user->toArray());
}
public function test_le_scope_admin_retourne_uniquement_les_admins(): void
{
User::factory()->admin()->create();
User::factory()->count(3)->create();
$this->assertCount(1, User::admin()->get());
}
}
class ProductTest extends TestCase
{
use RefreshDatabase;
public function test_le_scope_actif_filtre_correctement(): void
{
Product::factory()->count(3)->create(['actif' => true]);
Product::factory()->count(2)->create(['actif' => false]);
$this->assertCount(3, Product::active()->get());
}
public function test_il_appartient_a_un_utilisateur(): void
{
$user = User::factory()->create();
$produit = Product::factory()->create(['id_utilisateur' => $user->id]);
$this->assertTrue($produit->user->is($user));
}
}
Tests fonctionnels / HTTP
namespace Tests\Feature\Http\Controllers;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductControllerTest extends TestCase
{
use RefreshDatabase;
public function test_les_visiteurs_sont_rediriges_vers_la_connexion(): void
{
$this->get(route('products.create'))->assertRedirect(route('login'));
}
public function test_il_enregistre_un_nouveau_produit(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('products.store'), [
'nom' => 'Nouveau Produit',
'description' => 'Description',
'prix' => 2999,
'stock' => 10,
]);
$response->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', [
'nom' => 'Nouveau Produit',
'id_utilisateur' => $user->id,
]);
}
public function test_il_valide_les_champs_obligatoires(): void
{
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), [])
->assertSessionHasErrors(['nom', 'prix']);
}
public function test_les_utilisateurs_ne_peuvent_pas_modifier_les_produits_des_autres(): void
{
$proprietaire = User::factory()->create();
$attaquant = User::factory()->create();
$produit = Product::factory()->create(['id_utilisateur' => $proprietaire->id]);
$this->actingAs($attaquant)
->delete(route('products.destroy', $produit))
->assertForbidden();
}
}
Tests d'API JSON
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
public function test_les_requetes_non_authentifiees_sont_rejetees(): void
{
$this->getJson('/api/produits')->assertUnauthorized();
}
public function test_il_liste_les_produits_pagines(): void
{
$user = User::factory()->create();
Product::factory()->count(5)->create(['id_utilisateur' => $user->id]);
$response = $this->actingAs($user)->getJson('/api/produits');
$response->assertOk();
$response->assertJsonCount(5, 'data');
$response->assertJsonStructure([
'data' => [['id', 'nom', 'prix']],
'meta' => ['page_courante', 'derniere_page', 'total'],
]);
}
public function test_il_cree_un_produit(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/produits', [
'nom' => 'Produit API',
'prix' => 4999,
]);
$response->assertCreated();
$response->assertJsonPath('data.nom', 'Produit API');
}
public function test_les_utilisateurs_ne_peuvent_pas_supprimer_les_produits_des_autres(): void
{
$proprietaire = User::factory()->create();
$attaquant = User::factory()->create();
$produit = Product::factory()->create(['id_utilisateur' => $proprietaire->id]);
$this->actingAs($attaquant)
->deleteJson("/api/produits/{$produit->id}")
->assertForbidden();
}
}
Tests d'authentification Sanctum
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
public function test_les_utilisateurs_peuvent_s_inscrire(): void
{
$response = $this->postJson('/api/inscription', [
'nom' => 'Utilisateur Test',
'email' => '[email protected]',
'mot_de_passe' => 'MotDePasse123!',
'mot_de_passe_confirmation' => 'MotDePasse123!',
]);
$response->assertCreated();
$response->assertJsonStructure(['data' => ['utilisateur', 'jeton']]);
}
public function test_les_utilisateurs_peuvent_se_connecter(): void
{
User::factory()->create([
'email' => '[email protected]',
'password' => Hash::make('MotDePasse123!'),
]);
$response = $this->postJson('/api/connexion', [
'email' => '[email protected]',
'password' => 'MotDePasse123!',
]);
$response->assertOk();
$response->assertJsonStructure(['data' => ['jeton']]);
}
public function test_les_utilisateurs_ne_peuvent_pas_se_connecter_avec_un_mauvais_mot_de_passe(): void
{
User::factory()->create(['email' => '[email protected]']);
$this->postJson('/api/connexion', [
'email' => '[email protected]',
'password' => 'faux',
])->assertUnprocessable();
}
public function test_le_porteur_du_jeton_authentifie_les_requetes(): void
{
$user = User::factory()->create();
$jeton = $user->createToken('test')->plainTextToken;
$this->withToken($jeton)
->getJson('/api/utilisateur')
->assertOk()
->assertJsonPath('data.email', $user->email);
}
}
Mocking et Fakes
Fake HTTP
use Illuminate\Support\Facades\Http;
public function test_il_gerer_un_paiement_reussi(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'statut' => 'réussi'], 200),
]);
$resultat = (new PaymentService())->debiter(2999);
$this->assertTrue($resultat->succes);
}
public function test_il_gerer_un_echec_de_la_passerelle(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['erreur' => 'carte_refusee'], 402),
]);
$this->expectException(PaymentFailedException::class);
(new PaymentService())->debiter(2999);
}
public function test_il_renvoie_en_cas_de_timeout(): void
{
Http::fake([
'api.stripe.com/*' => Http::sequence()
->pushStatus(408)
->pushStatus(200),
]);
$this->assertTrue((new PaymentService())->debiter(2999)->succes);
}
Fake Courrier
Mail::fake();
$commande->envoyerConfirmation();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($commande) {
return $mail->hasTo($commande->user->email);
});
Fake Notification
Notification::fake();
$user->notify(new WelcomeUser());
Notification::assertSentTo($user, WelcomeUser::class);
Fake File d'attente
Queue::fake();
ProcessImage::dispatch($produit);
Queue::assertPushed(ProcessImage::class, function ($job) use ($produit) {
return $job->produit->id === $produit->id;
});
Fake Stockage
Storage::fake('public');
$fichier = UploadedFile::fake()->image('photo.jpg', 200, 200);
$response = $this->actingAs($user)->post('/avatar', [
'avatar' => $fichier,
]);
$response->assertSessionHasNoErrors();
Storage::disk('public')->assertExists('avatars/' . $fichier->hashName());
Fake Événement
Event::fake();
$commande->marquerCommeExpedie();
Event::assertDispatched(OrderShipped::class, function ($event) use ($commande) {
return $event->commande->id === $commande->id;
});
Tests des commandes Artisan
public function test_il_envoie_les_newsletters(): void
{
Mail::fake();
User::factory()->count(5)->create(['abonne' => true]);
$this->artisan('newsletter:envoyer')
->expectsOutput('Envoi de la newsletter à 5 abonnés...')
->assertExitCode(0);
Mail::assertSent(NewsletterMail::class, 5);
}
public function test_il_gerer_l_absence_d_abonnes(): void
{
$this->artisan('newsletter:envoyer')
->expectsOutput('Aucun abonné trouvé.')
->assertExitCode(0);
}
Tests d'autorisation
public function test_les_utilisateurs_peuvent_modifier_leurs_propres_articles(): void
{
$user = User::factory()->create();
$article = Post::factory()->create(['id_utilisateur' => $user->id]);
$this->actingAs($user)
->put(route('posts.update', $article), ['titre' => 'Mis à jour'])
->assertRedirect();
}
public function test_les_utilisateurs_ne_peuvent_pas_modifier_les_articles_des_autres(): void
{
$article = Post::factory()->create();
$this->actingAs(User::factory()->create())
->put(route('posts.update', $article), ['titre' => 'Piraté'])
->assertForbidden();
}
public function test_les_admins_peuvent_supprimer_n_importe_quel_article(): void
{
$admin = User::factory()->admin()->create();
$article = Post::factory()->create();
$this->actingAs($admin)
->delete(route('posts.destroy', $article))
->assertRedirect();
}
public function test_les_utilisateurs_standard_ne_peuvent_pas_supprimer_les_articles_des_autres(): void
{
$user = User::factory()->create();
$article = Post::factory()->create();
$this->actingAs($user)
->delete(route('posts.destroy', $article))
->assertForbidden();
}
Tests de validation
public function test_le_champ_email_doit_etre_unique(): void
{
User::factory()->create(['email' => '[email protected]']);
$this->postJson('/api/inscription', [
'nom' => 'Autre Utilisateur',
'email' => '[email protected]',
'mot_de_passe' => 'MotDePasse123!',
])->assertJsonValidationErrorFor('email');
}
public function test_le_prix_doit_etre_numerique(): void
{
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), [
'nom' => 'Produit',
'prix' => 'pas un nombre',
])->assertSessionHasErrors('prix');
}
Couverture de code
# Générer un rapport de couverture avec Xdebug ou PCOV
php artisan test --coverage
# Générer un rapport HTML détaillé
php artisan test --coverage-html=coverage
# Exiger un pourcentage minimum de couverture
php artisan test --coverage-min=80
Fichier de configuration de couverture PHPUnit
<phpunit>
<!-- ... -->
<source>
<include>
<directory suffix=".php">app</directory>
</include>
<exclude>
<directory suffix=".php">app/Providers</directory>
<file>app/Exceptions/Handler.php</file>
</exclude>
</source>
</phpunit>
Exemple de test avec Pest
<?php
it('crée un produit', function () {
$user = User::factory()->create();
actingAs($user);
$response = post(route('products.store'), [
'nom' => 'Produit Pest',
'description' => 'Description',
'prix' => 1500,
]);
$response->assertRedirect();
assertDatabaseHas('products', ['nom' => 'Produit Pest']);
});
it('rejette les champs invalides', function ($champ, $valeur, $regle) {
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), [$champ => $valeur])
->assertSessionHasErrors($champ);
})->with([
['nom', '', 'requis'],
['prix', 'abc', 'numérique'],
['prix', -5, 'minimum'],
['stock', 'ABC', 'entier'],
]);
it('garantit que seul le propriétaire peut supprimer', function () {
$proprietaire = User::factory()->create();
$attaquant = User::factory()->create();
$produit = Product::factory()->create(['id_utilisateur' => $proprietaire->id]);
actingAs($attaquant)
->delete(route('products.destroy', $produit))
->assertForbidden();
actingAs($proprietaire)
->delete(route('products.destroy', $produit))
->assertRedirect();
});
Pipelines CI (GitHub Actions Example)
nom : Tests Laravel
sur : [push, pull_request]
emplois :
tests :
exécutions-sur : ubuntu-latest
étapes :
- nom : Checkout
utilise : actions/checkout@v4
- nom : Configurer PHP
utilise : shivammathur/setup-php@v2
avec :
php-version : '8.2'
extensions : mbstring, sqlite, pdo_sqlite
couverture : pcov
- nom : Installer les dépendances
exécution : composer install --prefer-dist --no-progress
- nom : Environnement de test
exécution : cp .env.example .env && php artisan key:generate
- nom : Exécuter les tests
exécution : php artisan test --coverage --coverage-min=80
Meilleures pratiques
- Une assertion par test : chaque test doit vérifier un seul comportement
- Utiliser des noms de test descriptifs :
test_un_utilisateur_authentifie_peut_creer_un_produit - Suivre le cycle TDD : ROUGE → VERT → RÉUSINAGE
- Écrire des tests pour la logique métier : ne pas tester les gets/sets basiques à moins qu'ils n'aient un comportement personnalisé
- Utiliser les fakes au lieu de mocks complexes :
Http::fake(),Event::fake(), etc. - Garder les tests indépendants : chaque test doit s'exécuter isolément avec
RefreshDatabase - Tester les cas limites : valeurs vides, négatives, limites
- Ne pas simuler ce que vous ne possédez pas : préférer les fakes fournis par Laravel
Dépannage
| Problème | Solution |
|---|---|
SQLSTATE[HY000]: General error: 1 table already exists | Supprimer RefreshDatabase ou utiliser :memory: |
Session store not set on request | Ajouter extends TestCase et définir SESSION_DRIVER=array |
| Les tests envoient des emails réels | Vérifier MAIL_MAILER=array dans phpunit.xml |
The assertStatus method requires exception handling | Supprimer les appels à withoutExceptionHandling() dans setUp |
| Authentification Sanctum échouant en API | Ajouter EnsureFrontendRequestsAreStateful dans Kernel.php pour Sanctum SPA |
| Tests avec bases SQLite lents | Utiliser RefreshDatabase avec :memory: et minimiser les migrations |