Écrire des tests est de nos jours parfaitement inévitable dans nos applications modernes si vous souhaitez obtenir du code de qualité.
En plus de valider le bon fonctionnement de nos applications, les tests assurent une base solide permettant d'envisager plus sereinement des refactorisations en limitant le risque possible de régression.
Les tests automatisés sont la pierre angulaire de la qualité logicielle
PHPUnit, le framework de test les plus populaires de l'écosystème PHP, propose tout un lot de fonctionnalité facilitant l'écriture de ces tests.
L'une de ces fonctionnalités, les DataProvider, permettent de fournir automatiquement des jeux de données à une méthode de test afin de l'exécuter de multiples fois avec différentes entrées, il devient ainsi plus facile d'éprouver notre code en effectuant des batteries de tests.
Voyons ensemble comment utiliser les dataProvider dans Laravel !
la problématique
Avant d'écrire notre premier dataProvider, voyons à quelle problématique d'architecture de code ils répondent.
Pour ce faire, imaginons une class EnergyRating
déduisant l'indice énergétique à partir d'une consommation en kWh :
1<?php 2 3namespace App\Services; 4 5class EnergyRating 6{ 7 public static function getRating(int $consumption): string 8 { 9 return match (true) {10 $consumption <= 50 => 'A',11 $consumption <= 100 => 'B',12 $consumption <= 150 => 'C',13 $consumption <= 200 => 'D',14 $consumption <= 250 => 'E',15 $consumption <= 300 => 'F',16 default => 'G',17 };18 }19}
Maintenant que notre class EnergyRating
est fonctionnelle, ajoutons un test unitaire pour vérifier le bon fonctionnement de sa méthode getRating
:
1<?php 2 3namespace Tests\Unit; 4 5use Tests\TestCase; 6use App\Services\EnergyRating; 7 8class EnergyRatingTest extends TestCase 9{10 /**11 * @test12 */13 public function it_able_of_assigning_rating_A(): void14 {15 $rating = EnergyRating::getRating(10);16 17 $this->assertSame('A', $rating);18 }19}
Nous n'avons plus qu'à lancer la commande suivante et nous aurons un test prouvant que la méthode getRating
est capable de déduire l'indice énergetique A :
1vendor/bin/phpunit tests/Unit/EnergyRatingTest.php
Afin d'améliorer l'exhaustivité des tests, il sera essentiel de tester le service EnergyRating
dans diverses configurations ... en prenant en compte tous les cas d'utilisations possibles afin de valider l'entièreté des indices énergétiques.
Pour répondre à ce besoin, nous pourrions créer de multiples méthodes validant un par un les indices :
1class EnergyRatingTest extends TestCase 2{ 3 /** 4 * @test 5 */ 6 public function it_able_of_assigning_rating_A(): void 7 { 8 $rating = EnergyRating::getRating(10); 9 10 $this->assertSame('A', $rating);11 }12 13 /**14 * @test15 */16 public function it_able_of_assigning_rating_B(): void17 {18 $rating = EnergyRating::getRating(60);19 20 $this->assertSame('B', $rating);21 }22 23 /**24 * @test25 */26 public function it_able_of_assigning_rating_C(): void27 {28 $rating = EnergyRating::getRating(140);29 30 $this->assertSame('C', $rating);31 }32 33 // Etc ...34}
Cette solution, bien que totalement fonctionnelle et en adéquation avec de multiples critères de qualité concernant l'écriture des tests sera malheureusement relativement verbeuse à écrire et à maintenir.
Une autre solution serait d'effectuer plusieurs appels successifs au sein d'une même méthode :
1/** 2 * @test 3 */ 4public function it_able_of_determining_correct_ratings(): void 5{ 6 $rating = EnergyRating::getRating(10); 7 8 $this->assertSame('A', $rating); 9 10 $rating = EnergyRating::getRating(60);11 12 $this->assertSame('B', $rating);13 14 $rating = EnergyRating::getRating(140);15 16 $this->assertSame('C', $rating);17}
Cette seconde solution, moins judicieuse que la précédente s'avère également plus contraignantes et surtout bien plus sensible aux anomalies.
Accumuler des lots d'assertions au sein d'une méthode de test est une pratique risquée pouvant vous inciter à tester plusieurs cas d'utilisation consécutivement ... surchargeant rapidement votre méthode et la rendant difficile à comprendre.
Lorsqu'un test échoue, il devrait le faire pour une seule et unique raison, autrement dit, un test ne devrait valider qu'une seule conclusion pour être véritablement efficace.
it_able_of_assigning_rating_A
Lors de son exécution, PHPUnit s'arrêtera toujours à la première assertion bloquante d'une méthode ... cachant à vos yeux des anomalies potentielles sur les assertions ignorées, vos tests seront donc moins fiables et plus difficile à exploiter.
Ainsi, il est préférable de restreindre une méthode de test à la validation d'un unique cas d'utilisation, avec cette approche, vous améliorerez la pertinence de vos tests, renforcerez leur fiabilité et faciliterez la compréhension des erreurs en cas d'échec.
Voyons désormais comment les dataProvider peuvent répondre à nos différentes problématiques de manière élégante !
les dataProviders
Un dataProvider est une méthode statique renvoyant un array de plusieurs lignes appelé dataset
, chaque ligne de ce dataset contient un ensemble de valeurs qui seront utilisées comme arguments pour l'exécution de la méthode de test.
Chaque ligne de ce jeu de données correspond donc à une exécution de la méthode de test, il devient alors facile de créer un large dataset couvrant toutes les configurations possibles de notre méthode getRating
!
Pour associer un dataset
à une méthode de test, il est nécessaire d'utiliser l'attribut @dataProvider
dans la PHPDoc de la méthode :
1/** 2 * @test 3 * @dataProvider consumptionDataProvider 4 */ 5public function it_able_of_determining_correct_rating(int $consumption, string $expectedRating): void 6{ 7 $rating = EnergyRating::getRating($consumption); 8 9 $this->assertSame($expectedRating, $rating);10}11 12public static function consumptionDataProvider(): array13{14 return [15 [50, 'A'],16 [100, 'B'],17 [150, 'C'],18 [200, 'D'],19 [250, 'E'],20 [300, 'F'],21 [301, 'G'],22 ];23}
Nous prévoyons ici d'exécuter successivement la méthode de test 7 fois pour valider l'intégralité des indices !
1vendor/bin/phpunit --filter=it_able_of_determining_correct_rating
Vous avez également la possibilité de nommer chaque ligne du dataset pour rendre les potentiels messages d'erreur de PHPUnit bien plus explicite :
1public static function consumptionDataProvider(): array 2{ 3 return [ 4 'Low consumption should be assigned rating A' => [ 5 10, 'A', 6 ], 7 'Moderate consumption should be assigned rating B' => [ 8 60, 'B', 9 ],10 'High consumption should be assigned rating C' => [11 140, 'C',12 ],13 ];14}
En cas d'erreur, le message de PHPUnit sera désormais le suivant :
11) Tests\Unit\EnergyRatingTest::it_able_of_determining_correct_rating2with data set "Moderate consumption should be assigned rating B"
Sans cette précision supplémentaire le message sera bien plus cryptique :
11) Tests\Unit\EnergyRatingTest::it_able_of_determining_correct_rating2with data set #2
Pour finir, il est envisageable à des fins d'organisation de configurer plusieurs dataProvider pour une même méthode de test en multipliant les annotations :
1/** 2 * @test 3 * @dataProvider lowConsumptionDataProvider 4 * @dataProvider moderateConsumptionDataProvider 5 * @dataProvider highConsumptionDataProvider 6 */ 7public function it_able_of_determining_correct_rating(int $consumption, string $expectedRating): void 8{ 9 $rating = EnergyRating::getRating($consumption);10 11 $this->assertSame($expectedRating, $rating);12}
Un peu d'architecture
Intéressons-nous désormais à l'architecture de nos tests pour mieux apprehender le fonctionnement de PHPUnit.
En Laravel, un fichier de test n'est qu'une class héritant d'une autre class dénommée TestCase
, cette dernière contient toute la logique pour faire fonctionner vos tests, il en existe deux dans le framework :
- PHPUnit\Framework\TestCase
- Tests\TestCase
La première de ces class est le TestCase
proposé par PHPUnit alors que le second est celui de Laravel.
Le TestCase
de Laravel a beau hériter de celui de PHPUnit, une différence notable se trouve au niveau de ses dépendances, ce dernier possède en effet un trait CreatesApplication
permettant de "démarrer" une application Laravel dans vos tests :
1trait CreatesApplication 2{ 3 /** 4 * Creates the application. 5 */ 6 public function createApplication(): Application 7 { 8 $app = require __DIR__.'/../bootstrap/app.php'; 9 10 $app->make(Kernel::class)->bootstrap();11 12 return $app;13 }14}
Ces lignes sont similaires aux kernels http et artisan utilisés pour lancer Laravel !
Cela signifie une première chose, seule une class de test héritant de tests/TestCase
à la possibilité de manipuler des fonctionnalités de Laravel dans vos tests :
1class ExampleTest extends \Tests\TestCase 2{3 public function test_that_true_is_true(): void4 {5 echo config('app.name'); // Laravel 6 }7}
En héritant du testCase
de PHPUnit, Laravel sera inaccessible :
1class ExampleTest extends \PHPUnit\Framework\TestCase 2{3 public function test_that_true_is_true(): void4 {5 echo config('app.name'); // Error: Class "config" does not exist 6 }7}
Le choix du testCase
est donc relativement important car il déterminera les dépendances de vos class de tests.
Quand vous installez une nouvelle version de Laravel, deux placeholders sont présents par défaut dans son dossier /tests, ce dernier contient un test de feature et un test unitaire.
En étant attentif, vous remarquerez peut-être que seul le test de feature implémente le testCase
de Laravel, le test
unitaire implémente quant à lui le testCase
de PHPUnit.
Cela signifie que Laravel n'est pas censé être disponible dans vos tests unitaires, bien que contraignant cela rejoint une bonne pratique de qualité logicielle.
Un test unitaire est censé être modulable et totalement indépendant de votre framework et de votre base de données.
Vous imposer cette contrainte vous permettra de tester chaque brique fonctionnelle de votre application de manière totalement isolée et autonome sans être impacté par des dépendances externes qui sont de l'ordre de l'implementation.
La responsabilité de cette dernière reposant davantage sur les tests de feature et d'acceptance, qui eux, pourront communiquer avec votre framework.
Utiliser Laravel dans un dataProvider
Petit cas pratique avant de terminer cet article, il est possible que vous soyez tenté de contacter une fonctionnalité de Laravel à l'intérieur d'une méthode dataProvider comme dans l'exemple suivant :
1<?php 2 3namespace Tests\Unit; 4 5use Tests\TestCase; 6 7class WhateverTest extends TestCase 8{ 9 10 /**11 * @test12 * @dataProvider whateverDataProvider13 */14 public function whatever(string $name): void15 {16 echo $name;17 }18 19 public static function whateverDataProvider(): array20 {21 return [22 [config('app.name')], // Error23 ];24 }25}
En lançant ce test, vous vous apercevrez d'une erreur dans votre terminal, le helper config semble être inaccessible à votre dataProvider malgré l'utilisation du bon testCase
:
11) Tests\Unit\WhateverTest::whatever2The data provider specified for WhateverTest::whatever is invalid3Target class [config] does not exist.
Cette erreur apparaît car PHPUnit chargera en mémoire tous les dataProvider d'un fichier de test avant d'initialiser Laravel si vous utilisez le bon testCase
, cela signifie que le framework n'est pas directement accessible dans la méthode whateverDataProvider
.
Une pratique relativement simple à implémenter permettant de contourner cette restriction sera l'utilisation d'une closure pour "retarder" l'exécution du code :
1/** 2 * @test 3 * @dataProvider whateverDataProvider 4 */ 5public function whatever(\closure $name): void 6{ 7 echo $name(); // Laravel 8} 9 10public static function whateverDataProvider(): array11{12 return [13 [fn() => config('app.name')],14 ];15}
Dans cet exemple, l'exécution de la closure sera effectué à l'intérieur de la méthode whatever
quand Laravel sera disponible et non depuis la méthode whateverDataProvider
!
A lire
Autres articles de la même catégorie
Eclaircir le Test-Driven Development (TDD) avec Laravel
Explorons le Test Driven Development (TDD) avec Laravel
Marc COLLET
Quelques tips pour phpunit #1
Quelques tips pour améliorer vos performances et votre confort d’utilisation de phpunit.
Mathieu De Gracia
Le pattern Pipeline
Laravel dispose d'un puissant service de Pipeline méconnu de la plupart des développeurs, explorons ensemble les possibilités que propose ce pattern !
Mathieu De Gracia