Dans le monde du dev, il y a des running gag qui sont couramment employés dont cet adage : “Tester c’est douter”.
Ça claque sur un t-shirt mais faut avoir du cran pour l'appliquer en prod directement
Pendant longtemps j’ai cru que c’était vrai, mais j’ai eu la mauvaise aventure de livrer ma première application sans la tester car on m’avait dit “t’en fais pas, on testera plus tard”. Sauf que j’ai vite appris que “plus tard” c’est “jamais”, et l’application s’est avérée bancale en production. Et corriger une application en plein stress, c’est pas cool du tout.
Le testing, c’est redouté par tous et pour ma part j’avais l’impression de rentrer dans une nouvelle phase d’apprentissage (et j’apprends toujours) sans comprendre vraiment pourquoi je le fais, après tout, mon code est déjà écrit et je le teste avec des dump, pourquoi perdre du temps à faire des tests ?
Parce que ça permet de s’assurer que ton code fait bien ce que tu avais en tête, de prévoir tous les scénarios possibles et pouvoir ensuite adapter ton code en conséquence (oui oui, ton code hein pas ton test 😀, sinon ça sert à rien).
On distingue trois grandes catégories de tests : les tests unitaires, d’intégrations et fonctionnels.
Les tests unitaires, ce sont les tests qui vont tester tes morceaux de code.
Par exemple, si tu as une fonction getTaxForProduct() qui est censée te retourner la valeur de la T.V.A d’un produit, c’est le test unitaire qui va tester ta fonction.
Les tests d’intégrations sont utilisés pour tester tes services, tes repository, des api etc …
A l’inverse, les tests fonctionnels vont tester ton application à la façon d’un utilisateur: “va à cette page”, “clique ici”, “fais une requête GET”, “check ce que te retourne le GET” etc … On teste ici le comportement global de l’application.
Les tests unitaires sont plus rapides à écrire mais sont plus éloignés de la réalité de l'application, tandis que les tests fonctionnels sont plus longs à écrire, mais plus en adéquation avec la réalité. L'un ne va pas sans l'autre.
Pour démarrer, on va se créer un petit projet Symfo :
composer create-project symfony/skeleton:"7.2.x"my_first_tests
cd my_project_directory
On crée notre base de données via le .env et notre première entité “Product”.
| Propriété | Type | 
|---|---|
| id | integer | 
| name | string (unique) | 
| priceWithoutTax | float | 
| tax | integer | 
On a donc notre entité Product avec quelques propriétés basiques.
    public function __construct(string $name, float $priceWithoutTax, int $tax)  
    {      
        $this->name = $name;      
        $this->priceWithoutTax = $priceWithoutTax;      
        $this->tax = $tax;  
    }   
     
    public function getTotalPrice(): int  
    {      
        $productTax = $this->tax * $this->priceWithoutTax / 100;      
        return $productTax + $this->priceWithoutTax;  
    } 
On rajoute une petite fonction de calcul de prix total et on ajoute le __construct();
On va pouvoir débuter notre premier test.
1. Les tests unitaires
On va d’abord créer dans le dossier Test, les dossiers correspondant à nos fichiers que l’on doit tester. Ici je dois tester un bout de code qui se situe dans App\Entity\Product.
Je vais donc créer l’arborescence suivante : test/Unit/Entity/ProductTest.php
Chaque fichier de test doit correspondre au nom de votre classe et en y ajoutant Test à la fin.
//tests/Unit/Entity/ProductTest.php    
namespace App\Tests\Unit\Entity;    
use PHPUnit\Framework\TestCase;
  
class ProductTest extends TestCase  
{      
    public function testGetTotalPrice(): void      
    {      
    }  
}   
On y crée la classe correspondante et on extend de TestCase qui nous permet ensuite d’avoir accès aux méthodes de tests de PHPUnit.
Voila la base d’un fichier de test unitaire.
De cette façon, on peut ensuite créer notre objet Product et utiliser la fonction à tester :
    public function testGetTotalPrice(): void  
    {      
        $product = new Product("Pâtes", 2.5, 20);      
        $totalPrice = $product->getTotalPrice();      
        self::assertEquals(3, $totalPrice);  
    }   
On y ajoute ensuiteself::assertEquals(valeurDeRéférence, valeurDeTest)qui va indiquer à PHPUnit que le résultat attendu est censé être égal à la valeur qu’on lui donne (ici 3).
Le but du test c'est à partir d'un résultat connu à l'avance, de garantir que notre fonction à tester retourne bien ce résultat.
Toutes les méthodes d’assertion sont disponibles ici : docs.phpunit.de.
On utilisera ensuite en CLI la commande suivante :php bin/phpunitqui va lancer la procédure de test.
(Notre première assertion)
Le test est passé ! (Personnellement, l’adrénaline monte quand je lance la commande 😂).
Voyons voir maintenant avec un jeu de données plus conséquent.
    public static function productProvider(): array  
    {      
        return [         
            ["Pâtes", 2.5, 20, 3],         
            ["Riz", 2.2, 20, 2.64],         
            ["Huile d'olives", 5.47, 20, 6.564],         
            ["Lessive", 8.76, 20, 10.512],      
        ];  
    }      
    /**       
     * @dataProvider productProvider       
     */  
    public function testGetTotalPrice(string $name, float $priceWithoutTax, int $tax, float $totalPriceExpected): void  
    {      
        $product = new Product($name, $priceWithoutTax, $tax);      
        $totalPrice = $product->getTotalPrice();      
        self::assertEquals($totalPriceExpected, $totalPrice);  
    }   
L'annotation @dataProvider nous permet d’ajouter un jeu de données à notre test.
En l'ajoutant au dessus de notre fonction de test, automatiquement phpUnit utilisera le jeu de données ci-dessus.
On lance la commande php bin/phpunit et là … Oh non !

Un problème dans notre fonction.
(Ce n'est pas un échec, juste une occasion de s'améliorer !)
Quand le système indique un "." c’est que tout c’est bien passé, si il indique un "F" c’est que le test s’est mal passé. Ici, les trois nouveaux tests effectués ont échoué mais PHPUnit nous indique pourquoi plus bas :
(L'explication de PHPUnit)
On attend 2.64 sur la deuxième assertion mais la fonction getTotalPrice() nous donne 2. Il semble qu’on ait une erreur de type de retour dans notre entité. On va donc regarder ce qui se passe chez elle :
public function getTotalPrice(): int   
Effectivement, on attend un retour de type integer alors qu’on obtient finalement unfloat.
On modifie donc le int en float et on relance le test.
(ça passe !!!)
Test réussi pour cette fonction ! L’utilisation du test nous a permis de se rendre compte que certains prix peuvent être des nombres à virgules mais que la fonction retournait uniquement des entiers. Lors de mon premier essai j’ai eu de la chance de tomber sur un entier. Heureusement que j’ai testé, sinon je serais tombé dans le piège du “ça fonctionne, c’est bon” alors que … non.
L’avantage de tester son code dès le départ, c’est qu’à chaque modification, on relance les tests et on regarde si le résultat attendu est toujours bon.
Le nombre de tests correspond au nombre de tests lancés (nombre de fonctions de test et multiplié par le nombre de dataProvider) et le nombre d’assertions correspond au nombre d'assertions que l’on cherche à vérifier.
On va aussi ajouter une vérification sur une taxe négative qui entraînerait une valeur aberrante en y ajoutant une exception comme ceci :
// src/Entity/Product.php    
    public function __construct(string $name, float $priceWithoutTax, int $tax)  
    {      
        $this->name = $name;      
        $this->priceWithoutTax = $priceWithoutTax;      
        if($tax < 0)          
            throw new InvalidArgumentException("Tax invalid", 1);
            
        $this->tax = $tax;  
    }   
On crée ainsi une seconde fonction de test de création de produit :
// test/Unit/Entity/ProductTest.php    
/public function testCreateProductWithNegativeTax(): void  
{      
    self::expectException(InvalidArgumentException::class);      
    $product = new Product("Churros", 2, -3);  
}   
Ici petite particularité, on s’attend à l’exception avant qu’elle ne soit levée.
On lance le test :

On va s'arrêter là pour la partie des Tests Unitaires, c'est une bonne entrée en matière pour se lancer en solo !
2. Les tests d'intégrations
Les tests d'intégrations permettent de tester plus largement notre code testé unitairement.

Pour notre premier test d’intégration, j’ai créé 5 produits en DB via une migration :
// migrations/migration.php    
$this->addSql('INSERT INTO `product` (name, price_without_tax, tax) VALUES("Pâtes", 1.90, 20);');  $this->addSql('INSERT INTO `product` (name, price_without_tax, tax) VALUES("Riz", 1.60, 20);');  
$this->addSql('INSERT INTO `product` (name, price_without_tax, tax) VALUES("Café", 6.70, 10);');
$this->addSql('INSERT INTO `product` (name, price_without_tax, tax) VALUES("Lessive", 8.60, 20);');
$this->addSql('INSERT INTO `product` (name, price_without_tax, tax) VALUES("Vin", 5.47, 10);');   
On va se servir de cette base pour le test.
Tout d’abord, on va créer un nouveau dossier dans tests: Integration.
On va tester notre ProductRepository. Et tu as compris le principe => ProductRepositoryTest.php
Dans ProductRepository, on crée une fonction qui va nous retourner les produits ayant une taxe à 20%.
    /**  
    * @return Product[] Returns an array of Product objects
    */  
    public function findTaxEqualTwenty(): array  
    {     
        return $this->createQueryBuilder('p')->andWhere('p.tax = 20')->getQuery()->getResult();  
    }   
Puis on va créer notre méthode de test dans son homologue ProductRepositoryTest.php
// tests/Integration/Repository/ProductRespositoryTest.php    
class ProductRepositoryTest extends KernelTestCase  
{      
    public function testFindTaxEqualTwenty()      
    {          
        $repo = static::getContainer()->get(ProductRepository::class);          
        $products = $repo->findTaxEqualTwenty();          
        self::assertTrue(count($products) === 3);      
    }  
}   
Cette fois-ci, on extend avec KernelTestCase car on va tester des services, des containers etc … (Test d’intégration donc on utilise plus qu’un bout de code).
On lance la commande magique =>php bin/phpunit

Tu remarqueras qu'à chaque fois il lance aussi les anciens tests. Cela permet de garantir que ton code fonctionne toujours malgré les différents ajouts que tu peux lui faire et ça permet de garantir que ton code est robuste.
A présent on va tester l’ajout d’une insertion en DB, pour ça on peut créer une fonction testAddProduct (On est pas obligé de créer une fonction similaire dans notre repository, le fichier de test n’est pas forcément le miroir du fichier à tester).
    public function testAddProduct()  
    {      
        $product = new Product("Lait", 1.50, 20);      
        $entityManager = static::getContainer()->get(EntityManagerInterface::class);
        
        $entityManager->persist($product);      
        $entityManager->flush();      
        
        $repo = static::getContainer()->get(ProductRepository::class);      
        $result = $repo->findOneBy(['name' => "Lait"]);      
        
        $this->assertEquals($result->getName(), "Lait");  
    }   
On va donc utiliser le getContainer pour récupérer EntityManagerInterface.
Avant de lancer la commande de test, on a besoin de générer la base de données de tests car on va écrire dedans.
Pour ça on utilise la commande : php bin/console doctrine:database:create –env=test puis on migre avec php bin/console d:m:m –env=test
A présent on a 2 bases de données distinctes, celle du dev et celle des tests.
On peut donc maintenant lancer le test !

Pour la suite des tests tu corrigeras dans la testFindTaxEqualTwenty() la valeur attendue à 4 puisqu’on vient de rajouter un produit dans la base de test sinon tu auras une erreur.
Et puisque le nom du produit est unique, si tu ajoutes un nouveau produit nommé aussi “Lait”, alors tu auras une exception de contrainte unique. Lève là à l’aide d’un self::expectException() pour la seconde fonction de test :
$entityManager->persist($product);
self::expectException(UniqueConstraintViolationException::class);
$entityManager->flush();   
C’était pour la partie intégration, évidemment le sujet est beaucoup plus dense que ces quelques exemples mais tu as une bonne base pour débuter tes tests !
3. Les tests fonctionnels
La partie fonctionnelle c’est la partie qui représente vraiment le comportement utilisateur ou d’un système externe (si utilisation en tant que API).
Symfony nous donne des outils pour simuler une action user.
Pour cela on va créer un contrôleur ProductController comme ci-dessous :
// src/Controller/ProdutDisplayController.php    
class ProductDisplayController extends AbstractController  
{      
    #[Route(path: '/getProductsByTax/{tax}', name: 'getProductsByTax', methods: ['GET'])]      
    public function getProductsByTax(int $tax, ProductRepository $productRepo)      
    {          
        $products = $productRepo->findBy(["tax" => $tax]);      
            
        return new JsonResponse(array_map(function($product) {              
            return $product->getName();          
        }, $products));      
    }  
}   
Ce contrôleur est censé nous retourner les produits avec la taxe contenue dans la wildcard {tax}.
On pourrait la tester “classiquement” avec PostMan par exemple, mais le but c’est de ne pas faire d’action utilisateur nous mêmes car on veut automatiser ça en tests pour qu’ensuite, même en changeant notre code, cela fonctionnera toujours sans avoir besoin d’aller checker la route à chaque fois manuellement.
On crée notre dossier de tests fonctionnels dans “tests” et on y ajoute notre classe GetProductsTest.
// tests/Functional/GetProductsTest    
namespace App\Tests\Functional;    
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;    
class GetProductsTest extends WebTestCase  
{      
    public function testGetProductsByTax(): void      
    {          
        $client = static::createClient();          
        $client->request('GET', '/getProductsByTax/20');
        $response = $client->getResponse();          
        $this->assertResponseIsSuccessful();
                  
        $responseData = json_decode($response->getContent(), true);          $this->assertContainsEquals("Riz", $responseData);      
    }  
}   
La méthode statique createClient() permet d’avoir à disposition un navigateur virtuel et pouvoir effectuer des requêtes avec celui-ci.
On va s’en servir pour faire un request() de la route de notre contrôleur.
On y récupère notre réponse et on vérifie un premier test si la réponse est bien 200.
Ensuite on décode le json (car le contrôleur nous renvoie un json) puis on regarde si dans le Json se trouve le nom “Riz”.
On teste et… tadam !

4. Le Code Coverage
Utiliser PHPUnit seul c’est une bonne idée mais si on le couple avec Xdebug, alors on peut obtenir un rapport HTML de test afin de voir quels sont les codes qu’on a testé et dans quelle proportion par rapport au reste non testé.
Pour ça, il faut installer Xdebug et le paramétrer en mode “coverage”.
Ensuite on lance la commande : php bin/phpunit --coverage-html tests/coverage-report et tu obtiendras ton premier rapport de code coverage !
Notre premier rapport de code !

Ce rapport nous permet de voir à combien de pourcentage notre code est testé par classe, par fonctions.

Attention cependant à ne pas tomber dans le travers de “il faut que je teste tout mon code !!!!”.
La conception des tests doit être faite de manière réfléchie (pas juste pour avoir un code coverage tout vert). Ton attention doit se porter sur ce qui l’est vraiment :
- Les connexions et la sécurité
 - La logique métier
 - Les services externes
 
Inutile par exemple de tester les setters et le getters initialisés par symfony (sauf si vous y avez touché).
Petit bonus post tutoriel, Symfony nous offre une commande pour créer des tests avec le maker bundle : php bin/console make:test
Aide à la création des tests
On peut ensuite choisir la catégorie de tests (unitaire, fonctionnel, intégration …) et il va créer une classe prédéfinie.
Cependant c’est bien de savoir la générer soit même pour comprendre les dessous du test.
Voila, ce tuto est terminé, maintenant on peut transformer l’adage “Tester c’est douter” en “Tester c’est renforcer !”
