Au chapitre précédent, vous avez installé la librairie PHPUnit pour tester votre code PHP. Dans ce chapitre, vous allez vous lancer dans le développement de tests unitaires. Mais avant, nous allons voir concrètement ce qu’est un test unitaire, et en quoi cela consiste.
Comprenez le principe d’un test unitaire
Le principe d'un test unitaire est très simple. Il s'agit :
D'exécuter du code provenant de l'application à tester.
Et de vérifier que tout s'est bien déroulé comme prévu.
Cela signifie en général que vous allez vérifier que la valeur retournée est bien la valeur attendue et/ou que certaines méthodes ont bien été appelées.
On dit qu’un test unitaire est un test “boîte blanche”. À la différence d'un test “boîte noire", il faut connaître le code un minimum pour être en mesure de le tester ; c’est-à-dire l’avoir lu au moins une fois. C'est logique car, lors de l'écriture d'un test unitaire, vous allez devoir appeler le code explicitement.
Faites vos premiers tests unitaires
Créez les méthodes de test
Continuons notre cours avec notre version de PHPUnit fonctionnelle…
L’application que nous cherchons à tester s’appelle Food-diary, et sert à noter ce que l’on mange tous les jours, un peu comme un journal. Nous n’allons pas tester directement cette partie, mais plutôt le pricing avec le calcul de TVA, avec pour but futur de pouvoir créer une liste de courses, par exemple.
Nous allons reprendre le code que vous avez récupéré juste avant et contenant de la logique métier, pour pouvoir la tester unitairement ensuite. Dans notre projet, voici la classe Product
, ainsi qu'une méthode en charge du calcul de la TVA :
<?php
namespace App\Entity;
class Product
{
const FOOD_PRODUCT = 'food';
private $name;
private $type;
private $price;
public function __construct($name, $type, $price)
{
$this->name = $name;
$this->type = $type;
$this->price = $price;
}
public function computeTVA()
{
if (self::FOOD_PRODUCT == $this->type) {
return $this->price * 0.055;
}
return $this->price * 0.196;
}
}
Maintenant, vous allez tester la méthode computeTVA
.
Cette méthode n'a pas de point d'entrée (argument de méthode). En revanche, il nous faut un produit ayant un type et un prix, pour être en mesure de calculer la taxe sur la valeur ajoutée (TVA).
Combien de tests va-t-il falloir implémenter ?
Une indication pour connaître le nombre de tests qu'il va falloir écrire concerne les sorties possibles de la méthode. Dans le code présenté ci-dessus, vous voyez deux return
: il va falloir deux tests pour faire en sorte que tous les cas soient couverts.
Nous allons réaliser 2 tests :
Test n° 1 : lorsque le type vaut “food”.
Test n° 2 : lorsque le type est différent de food.
En fonction du type de produit rencontré, la TVA est différente. Il est donc indispensable de s’assurer que ce mécanisme est opérationnel et fonctionne bien, et c’est ce à quoi servent ces tests.
Pour le premier, le prix appliquera une TVA à 5,5 %, et pour le deuxième, une TVA à 19,6 %.
Test n° 1
Le premier test doit couvrir le cas permettant de faire en sorte d'atteindre la ligne 25 de la classe Product lors de l'exécution de code de la méthode computeTVA
. Il faut donc que le produit ait un type "food". Allons-y !
<?php
namespace App\Tests\Entity;
use App\Entity\Product;
use PHPUnit\Framework\TestCase;
class ProductTest extends TestCase
{
public function testcomputeTVAFoodProduct()
{
$product = new Product('Un produit', Product::FOOD_PRODUCT, 20);
$this->assertSame(1.1, $product->computeTVA());
}
}
Il s'agit de :
Construire l'objet dont nous allons avoir besoin pour appeler la méthode
computeTVA
.Puis de faire appel à PHPUnit pour effectuer une assertion.
Qu'est-ce qu'une assertion ?
Concrètement, du fait que nous étendons la classe PHPUnit\Framework\TestCase
, nous avons la possibilité d'appeler la méthode assertSame
qui prend en paramètres deux valeurs : la valeur attendue et le résultat du code exécuté. Le résultat devrait être 1.1 (puisque 20*0.055 = 1.1
).
Il ne vous reste plus qu'à lancer la suite de tests avec la commande :
$ vendor/bin/phpunit
Et voici le résultat que vous devriez obtenir :
Super ! Tout s'est bien passé !
Nous avons quelques informations communiquées :
le point (
.
) correspond à une méthode de test (un seul dans notre cas) ;la durée du test et la mémoire utilisée lors du lancement des tests ;
OK
correspond au statut global des tests de l'application. Dans le cas où au moins l'un des tests échoue, PHP affichera la sortie suivante :
Test n° 2
Il nous reste encore un chemin dans le code à tester : le cas où un produit possède un type différent de "food". Avant de regarder la correction, je vous invite à faire l'exercice seul, vous avez toutes les clés en main !
Créez une nouvelle méthode dans notre classe de test pour créer un produit ayant un type différent.
Puis appelez la méthode à tester.
Et enfin écrivez dans une assertion le résultat attendu :
<?php
namespace App\Tests\Entity;
use App\Entity\Product;
use PHPUnit\Framework\TestCase;
class ProductTest extends TestCase
{
// …
public function testComputeTVAOtherProduct()
{
$product = new Product('Un autre produit', 'Un autre type de produit', 20);
$this->assertSame(3.92, $product->computeTVA());
}
}
Lancez les tests de nouveau pour vous assurer que tout fonctionne comme prévu :
Et voilà ! Nous avons testé tous les cas présents dans le code fonctionnel. Notre code est maintenant bien plus robuste ! De plus, nous sommes désormais certains que lors d'un changement dans le code original, le fonctionnement du code est assuré grâce à nos tests unitaires automatisés.
Gérez les exceptions dans vos tests
Nous venons de voir que le nombre de return dans une méthode est une des indications pour connaître le nombre de tests à créer. Cependant, ce n'est pas la seule manière de sortir d'une méthode ! Le code écrit peut, en fonction des cas, lever des exceptions.
Reprenons notre code : nous allons y ajouter ce qu'il faut pour faire en sorte que lorsqu'un prix est négatif, une exception soit lancée. Ajoutez ce bout de code au début de votre méthode computeTVA, comme ci-dessous.
<?php
namespace App\Entity;
class Product
{
// …
public function computeTVA()
{
if ($this->price < 0) {
throw new Exception('The TVA cannot be negative.');
}
//…
}
}
Dans le cas où le prix du produit est inférieur à 0, une exception de type Exception
est lancée.
Il nous faut tester ce nouveau cas. Voici comment procéder :
<?php
namespace Tests\AppBundle\Entity;
use AppBundle\Entity\Product;
use PHPUnit\Framework\TestCase;
class ProductTest extends TestCase
{
// …
public function testNegativePriceComputeTVA()
{
$product = new Product('Un produit', Product::FOOD_PRODUCT, -20);
$this->expectException('Exception');
$product->computeTVA();
}
}
La façon de procéder est un peu différente de ce que vous avez vu précédemment.
Il s'agit d'indiquer ce qui est attendu avant d'exécuter le code à tester.
La méthode expectException
prend en paramètre une chaîne de caractères correspondant au FQCN (Full Qualified Class Name), c'est-à-dire le namespace complet de la classe de l'exception qui devrait être levée au moment de l'exécution du code.
Cette méthode nous permet d'ajouter une nouvelle assertion à notre suite de tests !
Dès qu’une exception est relevée dans les tests, le test passe en erreur, c’est tout à fait normal.
Créez une suite de tests avec des valeurs définies : les data providers
Avec les tests unitaires automatisés, il est important d’essayer de couvrir :
l'ensemble du code (les chemins) ;
mais aussi les cas limites liés à la logique métier de l'application.
C’est intéressant de s'assurer que son code fonctionne avec une suite de valeurs en entrée différentes, sans pour autant avoir à créer une méthode de test différente.
Pour répondre à cette problématique, il existe les “data providers” (“fournisseurs de données”, en français).
Modifions la méthode testcomputeTVAFoodProduct
pour faire en sorte d'exécuter cette méthode de tests avec un jeu de données particulier :
<?php
namespace App\Tests\Entity;
use App\Entity\Product;
use PHPUnit\Framework\TestCase;
class ProductTest extends TestCase
{
/**
* @dataProvider pricesForFoodProduct
*/
public function testcomputeTVAFoodProduct($price, $expectedTva)
{
$product = new Product('Un produit', Product::FOOD_PRODUCT, $price);
$this->assertSame($expectedTva, $product->computeTVA());
}
public function pricesForFoodProduct()
{
return [
[0, 0.0],
[20, 1.1],
[100, 5.5]
];
}
// …
}
Au moment où PHPUnit appelle la méthode testcomputeTVAFoodProduct
lors du lancement des tests, celle-ci sera en réalité appelée trois fois de suite en passant les paramètres suivants, tour à tour :
$price = 0 et $expectedTva = 0.0
.Puis
$price = 20 et $expectedTva = 1.1
.Et enfin
$price = 100 et $expectedTva = 5.5
.
Grâce à l'annotation @dataprovider
, PHPUnit est en mesure de récupérer les données via la méthode indiquée dans l'annotation ( pricesForFoodProduct
). Cette dernière doit retourner un tableau de tableaux, avec autant d'éléments que de paramètres que l'on souhaite passer à la méthode de test qui recevra les données pour les exploiter.
Tapez la commande suivante et voyez le résultat :
vendor/bin/phpunit --filter=testcomputeTVAFoodProduct
Comme vous pouvez le voir, même si nous lançons les tests uniquement pour une méthode de test, celle-ci est bien exécutée 3 fois.
Modifiez les règles de comportement de PHPUnit
Il est possible de configurer la manière dont PHPUnit doit se comporter lors du lancement des tests.
La modification du fichier de configuration peut servir à afficher les couleurs lors des tests, choisir le dossier de tests par défaut, et bien d’autres encore. Dans ces cas précis, il est judicieux de modifier ce fichier pour ne pas avoir à tout écrire dans la commande, mais là encore, ce ne sera que ponctuel.
Dans un projet Symfony, le fichier responsable de la configuration est contenu à la racine du projet, dans le fichier phpunit.xml.dist. Libre à vous de le modifier pour en modifier les règles.
Il est tout de même rare que vous ayez à le modifier.
Félicitations : vous avez réalisé vos premiers tests unitaires !
En résumé
Un test unitaire permet de tester un cas avec un ou plusieurs points d’entrée, et de s’assurer que la sortie est celle que l’on attend.
Il faut s’assurer de couvrir tous les chemins de code. Pour cela, un
return
ou le lancement d’une exception sont de bons indicateurs de tests à effectuer.Il faut néanmoins faire attention à ne pas vouloir avoir un taux de couverture de code (code coverage) à 100 %, car cela n'empêchera pas certains bugs, et c’est très chronophage ; donc privilégier ce qui est sensible.
Dans le prochain chapitre, vous allez apprendre à tester de façon unitaire du code qui est dépendant de systèmes externes. C’est parti !