Tour d'horizon des dataproviders

Tutoriels

É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 * @test
12 */
13 public function it_able_of_assigning_rating_A(): void
14 {
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 * @test
15 */
16 public function it_able_of_assigning_rating_B(): void
17 {
18 $rating = EnergyRating::getRating(60);
19 
20 $this->assertSame('B', $rating);
21 }
22 
23 /**
24 * @test
25 */
26 public function it_able_of_assigning_rating_C(): void
27 {
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(): array
13{
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_rating
2with 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_rating
2with 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(): void
4 {
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(): void
4 {
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 * @test
12 * @dataProvider whateverDataProvider
13 */
14 public function whatever(string $name): void
15 {
16 echo $name;
17 }
18 
19 public static function whateverDataProvider(): array
20 {
21 return [
22 [config('app.name')], // Error
23 ];
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::whatever
2The data provider specified for WhateverTest::whatever is invalid
3Target 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(): array
11{
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 !