Pourquoi écrire les tests en premier ?
La réponse courte : parce que si tu écris les tests après le code, tu ne testes pas vraiment ton code — tu vérifies que ton code fait ce que tu lui as déjà dit de faire. Le test devient une formalité.
En TDD, c’est l’inverse. Tu décris d’abord le comportement attendu (le test), tu constates qu’il échoue (normal, le code n’existe pas encore), puis tu écris le minimum de code pour le faire passer. Ce cycle force à penser à l’interface avant l’implémentation, et à n’écrire que ce qui est utile.
Le cycle s’appelle Red → Green → Refactor :
- Red — tu écris un test qui échoue
- Green — tu écris le minimum de code pour qu’il passe
- Refactor — tu nettoies sans casser les tests
Dans cet article, on applique ça sur une API Laravel de gestion de commandes, avec PHPUnit pour les tests et Mockery pour isoler les dépendances externes.
Configuration
PHPUnit est inclus dans Laravel par défaut. Rien à installer.
# Vérifier que les tests passent avant de commencerphp artisan testVérifiez votre phpunit.xml à la racine : assurez-vous que les tests utilisent une base en mémoire pour ne pas polluer la base de développement.
<php> <env name="APP_ENV" value="testing"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/></php>SQLite en mémoire est suffisant pour la majorité des tests — les requêtes sont rapides et la base est détruite après chaque run.
Le cas pratique : une API de commandes
On va construire un endpoint POST /api/orders qui crée une commande. Le comportement attendu :
- Un utilisateur authentifié peut créer une commande
- Un utilisateur non authentifié reçoit un 401
- Les données invalides retournent un 422
- Une fois créée, une notification est envoyée (via un service externe qu’on va mocker)
Créer le fichier de test d’abord
php artisan make:test Api/OrderControllerTest1. Red : écrire les tests qui échouent
<?phpnamespace Tests\Feature\Api;
use App\Models\Order;use App\Models\User;use App\Services\NotificationService;use Illuminate\Foundation\Testing\RefreshDatabase;use Mockery;use Tests\TestCase;
class OrderControllerTest extends TestCase{ use RefreshDatabase; // Remet la base à zéro entre chaque test
public function test_unauthenticated_user_cannot_create_order(): void { $response = $this->postJson('/api/orders', [ 'product_id' => 1, 'quantity' => 2, ]);
$response->assertStatus(401); }
public function test_authenticated_user_can_create_order(): void { $user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/orders', [ 'product_id' => 1, 'quantity' => 2, ]);
$response->assertStatus(201); $response->assertJsonStructure(['id', 'product_id', 'quantity', 'status']);
// On vérifie aussi que la commande est bien en base $this->assertDatabaseHas('orders', [ 'user_id' => $user->id, 'product_id' => 1, 'quantity' => 2, ]); }
public function test_order_creation_fails_with_missing_fields(): void { $user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/orders', []);
$response->assertStatus(422); $response->assertJsonValidationErrors(['product_id', 'quantity']); }}Lance les tests. Ils échouent tous — c’est normal, on n’a rien créé.
php artisan test --filter OrderControllerTest# FAIL : route not found, model not found, etc.2. Green : écrire le minimum pour faire passer les tests
Le modèle et la migration
php artisan make:model Order -mSchema::create('orders', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->unsignedInteger('product_id'); $table->unsignedInteger('quantity'); $table->string('status')->default('pending'); $table->timestamps();});La factory pour les tests
Les factories servent à créer des données de test rapidement, sans remplir la base à la main dans chaque test.
class OrderFactory extends Factory{ public function definition(): array { return [ 'user_id' => User::factory(), 'product_id' => fake()->numberBetween(1, 100), 'quantity' => fake()->numberBetween(1, 10), 'status' => 'pending', ]; }}La Form Request pour la validation
On isole la validation dans une Form Request plutôt que dans le contrôleur — c’est plus propre et plus facile à tester séparément.
php artisan make:request StoreOrderRequestclass StoreOrderRequest extends FormRequest{ public function authorize(): bool { return true; // L'autorisation est gérée par le middleware auth }
public function rules(): array { return [ 'product_id' => ['required', 'integer', 'min:1'], 'quantity' => ['required', 'integer', 'min:1', 'max:99'], ]; }}Le contrôleur
php artisan make:controller Api/OrderControllernamespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;use App\Http\Requests\StoreOrderRequest;use App\Models\Order;
class OrderController extends Controller{ public function store(StoreOrderRequest $request): \Illuminate\Http\JsonResponse { $order = Order::create([ 'user_id' => $request->user()->id, 'product_id' => $request->product_id, 'quantity' => $request->quantity, ]);
return response()->json($order, 201); }}La route
Route::middleware('auth:sanctum')->group(function () { Route::post('/orders', [OrderController::class, 'store']);});Lance les tests :
php artisan test --filter OrderControllerTest# PASS ✓3. Mocker une dépendance externe avec Mockery
Maintenant, on ajoute une notification envoyée après la création de la commande. Ce service appelle une API externe — on ne veut surtout pas l’appeler pendant les tests (lent, instable, et on ne veut pas envoyer de vraies notifications en test).
C’est là qu’intervient Mockery : on crée un faux NotificationService qui se comporte comme le vrai, mais sans appel réseau.
Le service
namespace App\Services;
class NotificationService{ public function notifyOrderCreated(int $userId, int $orderId): void { // Appel à une API de notification externe // En prod : HTTP call, webhook, email... }}Le test avec le mock
On ajoute un test qui vérifie que le service est bien appelé, sans l’exécuter réellement.
public function test_notification_is_sent_after_order_creation(): void{ $user = User::factory()->create();
// On crée un mock du NotificationService $mock = Mockery::mock(NotificationService::class);
// On indique ce qu'on attend : la méthode doit être appelée exactement une fois $mock->shouldReceive('notifyOrderCreated') ->once() ->with($user->id, Mockery::type('int'));
// On substitue le vrai service par le mock dans le conteneur Laravel $this->app->instance(NotificationService::class, $mock);
$this->actingAs($user)->postJson('/api/orders', [ 'product_id' => 1, 'quantity' => 2, ]);}Mettre à jour le contrôleur pour appeler le service
class OrderController extends Controller{ public function __construct(private NotificationService $notificationService) {}
public function store(StoreOrderRequest $request): \Illuminate\Http\JsonResponse { $order = Order::create([ 'user_id' => $request->user()->id, 'product_id' => $request->product_id, 'quantity' => $request->quantity, ]);
$this->notificationService->notifyOrderCreated($request->user()->id, $order->id);
return response()->json($order, 201); }}php artisan test --filter OrderControllerTest# PASS ✓ (4 tests)4. Refactor : nettoyer sans casser
Les tests passent, le code fonctionne. On peut maintenant nettoyer en confiance — déplacer de la logique dans un service, renommer des variables, extraire une méthode — à chaque changement les tests valident qu’on n’a rien cassé.
C’est le vrai bénéfice du TDD : le refactoring devient serein.
Quelques règles pratiques
Un test = un comportement, pas une méthode. test_authenticated_user_can_create_order teste un scénario complet, pas testStore.
RefreshDatabase ou DatabaseTransactions ?
RefreshDatabase: recrée les tables à chaque test (plus lent, plus propre)DatabaseTransactions: annule les requêtes après chaque test (plus rapide, mais ne fonctionne pas avec des connexions multiples)
Ne pas tester l’implémentation, tester le comportement. Un test ne doit pas savoir comment le code fonctionne en interne — seulement ce qu’il retourne. Si vous refactorisez l’implémentation sans changer le comportement, les tests ne doivent pas bouger.
Mocker uniquement les frontières système : APIs externes, emails, notifications, services de paiement. Ne pas mocker les modèles Eloquent ou les services internes — utilisez la vraie base SQLite en mémoire.
Conclusion
Le TDD demande un effort de démarrage : il faut penser à l’interface avant de coder. Mais très vite, les tests deviennent un filet de sécurité qui rend le refactoring plus rapide que sans tests. Sur une API Laravel, le cycle Form Request → Controller → Service → Mock couvre la majorité des cas courants et donne confiance pour faire évoluer le code sans régression.