Aller au contenu

Cours — Les différents types de tests avec Symfony 7.4


1. Pourquoi tester une application Symfony ?

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.


2. Les grandes familles de tests

Dans un projet Symfony, on distingue généralement 4 niveaux utiles de tests comme pour d’autres Frameworks d’ailleurs.

2.1 Test unitaire

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.

2.2 Test d’intégration

On teste la collaboration entre plusieurs briques (de code) comme les composants qui sont en interactions.

Exemples :

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.

2.3 Test fonctionnel web

On teste une route ou une page web avec le client de test Symfony.

Exemples :

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.

2.4 Test end-to-end (E2E)

On teste l’application dans un vrai navigateur.

Exemples :

Symfony recommande Panther pour ce type de test.


3. Préparer l’environnement de test

Dans un projet Symfony, les tests sont généralement placés dans le dossier 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 :

Installation typique

composer require --dev symfony/test-pack

Pour les tests E2E avec un vrai navigateur :

composer require --dev symfony/panther

4. Les tests unitaires

4.1 Définition simple

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.

On teste une classe comme si elle vivait seule.

C’est le test :

4.2 Exemple simple

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);
    }
}

Test unitaire associé

<?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);
    }
}

4.3 Ce qu’il faut retenir

Dans ce test :

4.4 Quand utiliser un test unitaire ?

Utilisez-le pour effectuer les vérifications suivantes :

Évitez de l’utiliser pour :


5. Les tests d’intégration

5.1 Définition simple

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.

5.2 Exemple simple

Supposons que OrderTotalService utilise 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);
    }
}

Test d’intégration

Vous remarquez que l’on nomme toujours les classes de tests avec le mot clef Test comme suffixe.

<?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);
    }
}

5.3 Pourquoi ce n’est plus un test unitaire ?

Parce que :

5.4 Quand utiliser un test d’intégration ?

Utilisez-le pour les cas suivants :


6. Les tests fonctionnels web

6.1 Définition simple

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.

6.2 Exemple de contrôleur

<?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>');
    }
}

Test fonctionnel

<?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');
    }
}

6.3 Ce que fait ce test

6.4 Exemple avec formulaire

<?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();
    }
}

6.5 Quand utiliser ce type de test ?

Utilisez-le pour :


7. Les tests end-to-end E2E

7.1 Définition

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.

7.2 Pourquoi les tests E2E sont utiles ?

Ils sont très utiles pour :

Mais ils sont aussi :

7.3 Exemple simple avec Panther

<?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');
    }
}

7.4 Quand choisir E2E ?

Choisissez E2E seulement si vous devez vraiment tester :

En clair :


8. Quel type de test choisir ?

Voici une règle simple, si vous testez… :

L’erreur classique consiste à vouloir tout tester en E2E !


9. Exemple simple complet

Prenons un mini cas.

Nous avons :

9.1 Service

<?php
namespace App\Service;

class GreetingService
{
    public function getMessage(string $name): string
    {
        return sprintf('Bonjour %s', $name);
    }
}

9.2 Contrôleur

<?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))
        );
    }
}

9.3 Test unitaire

<?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'));
    }
}

9.4 Test d’intégration

<?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'));
    }
}

9.5 Test fonctionnel

<?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');
    }
}

9.6 Test E2E

<?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');
    }
}

10. Les bonnes pratiques

10.1 Commencer petit

Ne cherchez pas à tester toute l’application d’un coup.

Commencez par :

10.2 Garder des tests lisibles

Un bon test doit se lire presque comme une phrase. Voir les règles du Clean Code.

Mauvais signe :

10.3 Tester un comportement, pas une implémentation

On teste ce que le code doit faire, pas la manière exacte dont il le fait.

10.4 Préférer beaucoup de tests unitaires

Ils sont rapides et stables.

Ajoutez ensuite :

10.5 Éviter les tests inutiles

Un test doit contenir au moins une vraie assertion ou une attente pertinente.


11. Commandes utiles

Exécuter tous les tests

php bin/phpunit

Exécuter un seul fichier

php bin/phpunit tests/Unit/Service/PriceCalculatorTest.php

Filtrer par nom de test

php bin/phpunit --filter testApplyDiscountReturnsDiscountedPrice

Affichage plus lisible

php bin/phpunit --testdox

12. Conclusion

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 :

  1. beaucoup de tests unitaires
  2. quelques tests d’intégration
  3. quelques tests fonctionnels importants
  4. très peu de tests E2E, ciblés sur les parcours critiques

Petit exercice conseillé

Créez un mini service TaxeCalculator avec une méthode :

public function addTaxe(float $prix): float

Puis écrivez :

  1. un test unitaire pour vérifier le calcul
  2. un test d’intégration pour récupérer le service dans le conteneur
  3. une route /taxe/{prix} qui affiche le résultat
  4. un test fonctionnel pour vérifier l’affichage
  5. un test E2E si vous voulez vérifier la page dans un vrai navigateur.

The End.