Retour

/ 6 min read

TDD avec Laravel et PHPUnit

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 :

  1. Red — tu écris un test qui échoue
  2. Green — tu écris le minimum de code pour qu’il passe
  3. 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.

Terminal window
# Vérifier que les tests passent avant de commencer
php artisan test

Vé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.

phpunit.xml
<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

Terminal window
php artisan make:test Api/OrderControllerTest

1. Red : écrire les tests qui échouent

tests/Feature/Api/OrderControllerTest.php
<?php
namespace 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éé.

Terminal window
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

Terminal window
php artisan make:model Order -m
database/migrations/xxxx_create_orders_table.php
Schema::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.

database/factories/OrderFactory.php
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.

Terminal window
php artisan make:request StoreOrderRequest
app/Http/Requests/StoreOrderRequest.php
class 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

Terminal window
php artisan make:controller Api/OrderController
app/Http/Controllers/Api/OrderController.php
namespace 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

routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::post('/orders', [OrderController::class, 'store']);
});

Lance les tests :

Terminal window
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

app/Services/NotificationService.php
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);
}
}
Terminal window
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.