Tester une application consiste à vérifier automatiquement que le code fait bien ce que l’on attend de lui.
Avec Symfony, les tests servent notamment à :
En pratique, un bon test remplace une vérification manuelle répétitive. Au lieu d’ouvrir votre navigateur après chaque modification, vous laissez PHPUnit ou Symfony exécuter ces vérifications pour vous. Symfony s’appuie sur PHPUnit pour exécuter les tests, et propose plusieurs classes utiles selon le niveau de test voulu, comme KernelTestCase pour l’intégration et WebTestCase pour les tests d’application web.
KernelTestCase
WebTestCase
Dans un projet Symfony, on distingue généralement 4 niveaux utiles de tests comme pour d’autres Frameworks d’ailleurs.
On teste une petite unité de code isolée.
Exemples :
Ici, on évite de dépendre du framework, de la base de données ou du navigateur.
On teste la collaboration entre plusieurs briques (de code) comme les composants qui sont en interactions.
Dans Symfony, ce type de test se fait souvent avec KernelTestCase, qui démarre le noyau de l’application et donne accès au conteneur de services.
On teste une route ou une page web avec le client de test Symfony.
/contact
Ici, on utilise souvent WebTestCase, qui s’appuie sur BrowserKit et DomCrawler pour simuler un navigateur. On pourrait aussi faire ce genre de tests avec Postman ou Bruno.
On teste l’application dans un vrai navigateur.
Symfony recommande Panther pour ce type de test.
Dans un projet Symfony, les tests sont généralement placés dans le dossier tests/.
tests/
Exemple d’organisation :
tests/ ├── Unit/ │ └── Service/ ├── Integration/ │ └── Service/ ├── Controller/ └── E2E/
Les tests sont exécutés avec PHPUnit. Un test suit souvent la structure ci-dessous :
composer require --dev symfony/test-pack
Pour les tests E2E avec un vrai navigateur :
composer require --dev symfony/panther
Un test unitaire vérifie une petite portion de code sans démarrer Symfony. Souvenez-vous de Java avec JUnit5 pour les tests unitaires. C’est un peu la même chose avec Symfony. Dans notre cas, il suffira d’hériter de TestCase.
TestCase
On teste une classe comme si elle vivait seule.
C’est le test :
Imaginons un service qui calcule une remise.
<?php // src/Service/PriceCalculator.php namespace App\Service; class PriceCalculator { public function applyDiscount(float $price, float $discountPercent): float { if ($discountPercent < 0 || $discountPercent > 100) { throw new \InvalidArgumentException('Pourcentage invalide !'); } return $price - ($price * $discountPercent / 100); } }
<?php // tests/Unit/Service/PriceCalculatorTest.php namespace App\Tests\Unit\Service; use App\Service\PriceCalculator; use PHPUnit\Framework\TestCase; class PriceCalculatorTest extends TestCase { // Ici on le test doit être valide public function testApplyDiscountReturnsDiscountedPrice(): void { $calculator = new PriceCalculator(); $result = $calculator->applyDiscount(100, 20); $this->assertSame(80.0, $result); } // ici on souhaite lancer une exception public function testApplyDiscountThrowsExceptionWhenDiscountIsInvalid(): void { $calculator = new PriceCalculator(); $this->expectException(\InvalidArgumentException::class); $calculator->applyDiscount(100, 120); } }
Dans ce test :
Utilisez-le pour effectuer les vérifications suivantes :
Évitez de l’utiliser pour :
Un test d’intégration vérifie que plusieurs éléments fonctionnent ensemble. Ici, on accepte de démarrer Symfony. Le but n’est plus de tester une méthode isolée, mais un petit ensemble cohérent. Dans Symfony, on utilise souvent KernelTestCase, qui démarre le noyau et permet d’accéder au conteneur de services.
Supposons que OrderTotalService utilise PriceCalculator.
OrderTotalService
PriceCalculator
On reste en anglais car j’ai vu dans vos applications que vous préférez rédiger votre code dans cette langue.
<?php // src/Service/OrderTotalService.php namespace App\Service; class OrderTotalService { // injection de dépendance public function __construct(private PriceCalculator $calculator) { } public function getFinalTotal(float $price): float { return $this->calculator->applyDiscount($price, 10); } }
Vous remarquez que l’on nomme toujours les classes de tests avec le mot clef Test comme suffixe.
Test
<?php // tests/Integration/Service/OrderTotalServiceTest.php namespace App\Tests\Integration\Service; use App\Service\OrderTotalService; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class OrderTotalServiceTest extends KernelTestCase { public function testGetFinalTotalUsesRealServiceConfiguration(): void { self::bootKernel(); $service = static::getContainer()->get(OrderTotalService::class); $result = $service->getFinalTotal(200); $this->assertSame(180.0, $result); } }
Parce que :
Utilisez-le pour les cas suivants :
Un test fonctionnel web vérifie le comportement d’une page ou d’une route. Ici, on simule un navigateur avec le client de test Symfony, mais sans lancer un vrai navigateur graphique.
WebTestCase ajoute cette logique au-dessus de KernelTestCase.
<?php // src/Controller/HelloController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class SalutController extends AbstractController { #[Route('/salut', name: 'app_salut')] public function index(): Response { return new Response('<h1>Bonjour les apprenant.e.s</h1>'); } }
<?php // tests/Controller/SalutControllerTest.php namespace App\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class SalutControllerTest extends WebTestCase { public function testSalutPageIsSuccessful(): void { $client = static::createClient(); $crawler = $client->request('GET', '/salut'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Bonjour les apprenant.e.s'); } }
<?php // tests/Controller/ContactControllerTest.php namespace App\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class ContactControllerTest extends WebTestCase { public function testContactFormCanBeSubmitted(): void { $client = static::createClient(); $crawler = $client->request('GET', '/contact'); $form = $crawler->selectButton('Envoyer')->form([ 'contact[name]' => 'Philippe', 'contact[email]' => 'philippe@numerosoft.com', 'contact[message]' => 'Bonjour', ]); $client->submit($form); $this->assertResponseRedirects(); } }
Utilisez-le pour :
Un test E2E vérifie le comportement réel de l’application dans un vrai navigateur.
Contrairement à WebTestCase, ici on teste aussi :
Symfony recommande Panther pour cela.
Ils sont très utiles pour :
Mais ils sont aussi :
<?php // tests/E2E/HomePageTest.php namespace App\Tests\E2E; use Symfony\Component\Panther\PantherTestCase; class HomePageTest extends PantherTestCase { public function testHomePageDisplaysMainTitle(): void { $client = static::createPantherClient(); $crawler = $client->request('GET', 'http://127.0.0.1:8000/'); $this->assertSelectorTextContains('h1', 'Bienvenue'); } }
Choisissez E2E seulement si vous devez vraiment tester :
En clair :
Voici une règle simple, si vous testez… :
L’erreur classique consiste à vouloir tout tester en E2E !
Prenons un mini cas.
Nous avons :
<?php namespace App\Service; class GreetingService { public function getMessage(string $name): string { return sprintf('Bonjour %s', $name); } }
<?php namespace App\Controller; use App\Service\GreetingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class GreetingController extends AbstractController { #[Route('/greeting/{name}', name: 'app_greeting')] public function index(string $name, GreetingService $greetingService): Response { return new Response( sprintf('<h1>%s</h1>', $greetingService->getMessage($name)) ); } }
<?php namespace App\Tests\Unit\Service; use App\Service\GreetingService; use PHPUnit\Framework\TestCase; class GreetingServiceTest extends TestCase { public function testGetMessageReturnsExpectedString(): void { $service = new GreetingService(); $this->assertSame('Bonjour Philippe', $service->getMessage('Philippe')); } }
<?php namespace App\Tests\Integration\Service; use App\Service\GreetingService; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class GreetingServiceIntegrationTest extends KernelTestCase { public function testServiceIsAvailableInContainer(): void { self::bootKernel(); $service = static::getContainer()->get(GreetingService::class); $this->assertSame('Bonjour Philippe', $service->getMessage('Philippe')); } }
<?php namespace App\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class GreetingControllerTest extends WebTestCase { public function testGreetingPageDisplaysMessage(): void { $client = static::createClient(); $client->request('GET', '/greeting/Philippe'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Bonjour Philippe'); } }
<?php namespace App\Tests\E2E; use Symfony\Component\Panther\PantherTestCase; class GreetingPageE2ETest extends PantherTestCase { public function testGreetingPageInRealBrowser(): void { $client = static::createPantherClient(); $client->request('GET', 'http://127.0.0.1:8000/greeting/Philippe'); $this->assertSelectorTextContains('h1', 'Bonjour Philippe'); } }
Ne cherchez pas à tester toute l’application d’un coup.
Commencez par :
Un bon test doit se lire presque comme une phrase. Voir les règles du Clean Code.
Mauvais signe :
On teste ce que le code doit faire, pas la manière exacte dont il le fait.
Ils sont rapides et stables.
Ajoutez ensuite :
Un test doit contenir au moins une vraie assertion ou une attente pertinente.
php bin/phpunit
php bin/phpunit tests/Unit/Service/PriceCalculatorTest.php
php bin/phpunit --filter testApplyDiscountReturnsDiscountedPrice
php bin/phpunit --testdox
Dans Symfony 7.4, il faut retenir cette logique simple :
La meilleure stratégie n’est pas de tout tester au même niveau.
La meilleure stratégie est souvent :
Créez un mini service TaxeCalculator avec une méthode :
TaxeCalculator
public function addTaxe(float $prix): float
Puis écrivez :
/taxe/{prix}
The End.