Top Code 2024, les challenges sont de nouveau disponibles dans les boards pour les participant(e)s => Boards

Tests unitaires en PHP #3 : TDD avec PHPUnit et des dataprovider

On résout un challenge en TDD, pas à pas, avec PHPUnit et des dataprovider.

→ Corrigé du Challenge : WALL-E #1

Pour présenter tout ça, on va s’appuyer sur le challenge WALL_E. Le but est de programmer la gestion des déchets par le petit robot.

On passera en revue ces points, avec des exemples de code concrets :

  • TDD : Test Driven Developpement. En 2 mots, cela signifie que l’on commencera par écrire les tests avant d’écrire le code. Et on codera jusqu’à réussir nos tests.
  • PHPUnit : c’est le framework de tests PHP qu’on utilisera. Si tu n’es pas encore familier du framework, tu peux te reporter à ces 2 corrigés : PHPUnit #1 et PHPUnit #2.
  • Dataprovider : C’est une fonctionnalité de PHPUnit qui permet de passer un jeu de données à un test et de réaliser plusieurs fois le même test avec des données différentes. On évite donc de réécrire le même test avec seulement des données qui changent.
  • PHP 8 : On utilisera 3 nouveautés de PHP 8, la promotion dans le constructeur, les paramètres nommés et les multiples types de retour.

Ce corrigé s’appuie sur l’organisation du code proposé dans la Sandbox PHP. De cette manière, les dossiers, fichiers et namespaces nécessaires sont prêts à être utilisés.

Je travaille ici à 2 endroits :

  • Dans le dossier /challenges/WALL_E où je vais créer l’objet Robot.php
  • Dans le dossier /phpunit/WALL_E, dans le fichier Wall_eTest.php

POO : analyse du problème et premier objet

Pour résoudre le challenge, et pour résoudre un problème en général, je commence par copier l’énoncé dans mon fichier, et je mets tout en commentaire. Comme ça, toutes les explications de mon problème sont dans mon code. Allons-y bloc par bloc.

Je crée un objet Robot pour résoudre ce challenge :

<?php
declare(strict_types=1);

namespace Challenges\WALL_E;

final class Robot
{
    // Wall-E démarre avec les caractéristiques suivantes :
    // Une force qui permet de déterminer comment il porte les déchets (un entier entre 10 et 20).
    // Une vitesse qui permet de savoir s’il déplace rapidement (un entier entre 5 et 15).
    // Un niveau de batterie, qui va évoluer dans le temps (au départ un entier entre 70 et 100).
}

Je crée les propriétés associées :

final class Robot
{
    // Wall-E démarre avec les caractéristiques suivantes :

    // Une force qui permet de déterminer comment il porte les déchets (un entier entre 10 et 20).
    private int $force;

    // Une vitesse qui permet de savoir s’il déplace rapidement (un entier entre 5 et 15).
    private int $vitesse;

    // Un niveau de batterie, qui va évoluer dans le temps (au départ un entier entre 70 et 100).
    private int $batterie;
}

Promotion dans le constructeur avec PHP 8

Je souhaite maintenant initialiser mes propriétés dans mon constructeur. C’est là que je peux utiliser la promotion dans le constructeur, disponible dans PHP 8 :

final class Robot
{
    public function __construct(

        // Permet de déterminer comment il porte les déchets
        private int $force,

        // Permet de savoir comment il se déplace
        private int $vitesse,

        // Un niveau de batterie, qui va évoluer dans le temps
        private int $batterie,
    )
    {
    }
}

Je ne garde que les commentaires pertinents à la compréhension de mon programme.

Je poursuis l’énoncé. Maintenant que mon objet est démarré, je peux passer à mes tests.

TDD – Test Driven Developpement

Je prépare mon fichier de test et j’y colle encore les éléments de mon énoncé :

<?php
declare(strict_types=1);

use Challenges\WALL_E\Robot;
use PHPUnit\Framework\TestCase;

final class Wall_eTest extends TestCase
{
	public function setUp(): void
	{
		parent::setUp();
	}

    // Chaque traitement de déchet va coûter de la batterie.
    // Si la force de Wall-E est supérieure ou égale au poids du déchet, Wall-E le traite sans soucis. Cela ne lui coute que 1% de batterie.
    // Si Wall-E n’a pas assez de force, il peut puiser dans sa batterie pour augmenter sa force initiale (seulement pour le déchet en cours de traitement) :
    // 1 pt de force supplémentaire coute 2% de batterie.
    // Wall-E ne peut pas dépenser + de la moitié de sa batterie courante pour augmenter sa force.
    // S’il manque 4pt de force à Wall-E, il dépensera donc 8% de batterie pour traiter le déchet (s’il a assez de batterie)
    // Si malgré la batterie, il n’a pas assez de force pour traiter le déchet, Wall-E perd 2% de batterie et ne traite pas le déchet. Il ne sera plus jamais traité.
}

De cette manière, j’ai positionné dans mon code les informations importantes, je vais venir coder autour puis épurer les commentaires au fur et à mesure.

TDD : Le premier test

Je vais commencer par chercher combien de batterie dépense Wall-E pour traiter un déchet. Pour vérifier la première règle, je vais considérer que la force de Wall-E est supérieure au poids du déchet. Par exemple une force de 20 et un poids du déchet de 18. Cela doit donc me coûter 1% de batterie. Voici le test :

public function test_force_superieure_poids_objet(): void
{
	$walle = new Robot(
		force: 20,
		vitesse: 10,
		batterie: 100
	);
	
	$poidsDechet = 18;

	$this->assertEquals(
		$walle->batteriePourDechet($poidsDechet),
		1
	);
}

Ma méthode commence par le mot-clé « test ».

J’utilise dans le constructeur de Robot les paramètres nommés (PHP 8). De cette façon, je visualise bien à quoi correspond chaque valeur, il n’est plus nécessaire de me souvenir de l’ordre ou de me reporter à la déclaration de mon objet. L’ordre pourrait même changer.

Je vérifie donc que la batterie utilisée pour ce déchet vaut 1 (la méthode n’existe pas encore).

Si tu lances le test, tu vas avoir une erreur, qui va t’indiquer, fort logiquement : Call to undefined method Challenges\WALL_E\Robot::batteriePourDechet() !

TDD : on passe le premier test en vert !

Retournons dans notre objet Robot et codons donc cette fonction :

public function batteriePourDechet(int $poidsDechet): int
{
    return 1;
}

Et hop, le test est OK ! Mais attends… La méthode va être + compliquée que ça, non ? Oui, mais pour l’instant, elle me suffit pour valider mon test. Donc si je veux la complexifier, il faut d’abord que j’écrive les tests associés.

TDD : on précise les tests avec plus de données

Faisons un état de ce qu’il peut se passer (on considère toujours 20 de force) :

  • On a testé une force supérieure au poids, mais la force peut être égale. Donc si le poids du déchet fait 20, je vais dépenser aussi 1% de batterie.
  • Maintenant, si le poids est + élevé, je vais utiliser 2% de batterie par point de différence entre la force et le poids :
    • Poids de 21 => 2% de batterie
    • Poids de 22 => 4% de batterie
    • Poids de 25 => 10% de batterie
    • Poids de 30 => 20% de batterie
  • Autre point de l’énoncé, Wall-E ne peut pas dépenser + de la moitié de sa batterie d’un coup. On garde 20 de force, mais seulement 40 de batterie, donc une dépense maximale de 20%.
    • Poids de 21 => 2% de batterie => OK
    • Poids de 25 => 10% de batterie => OK
    • Poids de 30 => 20% de batterie => OK
    • Poids de 35 => 30% de batterie => KO => 2% de batterie (et déchet non traité)

Si je reprends le premier cas, on arrive à 2 + 4 + 4 = 10 cas à tester. Soit 10 tests à écrire… Sauf que ces 10 tests vont beaucoup se ressembler.

Dataprovider avec PHPUnit

C’est là que rentrent en jeu les dataprovider. Je vais créer une méthode qui va me retourner un tableau contenant tous ces jeux de tests :

public function providerBatterieDepenseeSelonPoidsDechets(): array
{
    // batterie de départ, poids, batterie dépensée
    return [
        // Si la force de Wall-E est supérieure ou égale au poids du déchet, cela ne lui coute que 1% de batterie.
        [100, 18, 1],
        [100, 20, 1],

        // Si Wall-E n’a pas assez de force, il peut puiser dans sa batterie pour augmenter sa force initiale

        // 1 pt de force supplémentaire coute 2% de batterie.
        [100, 21, 2],
        [100, 22, 4],
        [100, 23, 6],
        [100, 30, 20],

        // Batterie différente
        [40, 21, 2],
        [40, 25, 10],
        [40, 30, 20], // Pile la moitié

        // Wall-E ne peut pas dépenser + de la moitié de sa batterie courante pour augmenter sa force.
        // Si malgré la batterie, il n’a pas assez de force pour traiter le déchet, Wall-E perd 2% de batterie
        [40, 35, 2]
    ];
}

Cette méthode commence par le mot-clé « provider », elle retournera toujours un tableau.

J’ai déplacé mes commentaires qui font office de documentation de mes jeux de données.

Je peux écrire un nouveau test, qui utilisera ces données :

/**
 * @dataProvider providerBatterieDepenseeSelonPoidsDechets
 */
public function test_batterie_pour_soulever_dechet(
    int $batterieDepart,
    int $poidsDechet,
    int $batterieDepensee
    ): void
{
    $walle = new Robot(
        force: 20,
        vitesse: 10,
        batterie: $batterieDepart
    );

    $this->assertEquals(
        $walle->batteriePourDechet($poidsDechet),
        $batterieDepensee
    );
}

Il faut utiliser la phpdoc (le bloc de commentaires juste avant la déclaration de la fonction) pour désigner le provider qui sera utilisé grâce à @dataProvider.

La méthode du test prend des paramètres, qui sont, dans l’ordre, les données retournées par le dataprovider.

Ces paramètres sont donc utilisés selon les bonnes correspondances.

TDD : on a cassé les tests !

Quand je lance ce test, j’obtiens :

Aïe du rouge…

Les 2 tests qui passent sont les 2 premiers pour lesquels on attend 1 puisque ma méthode batteriePourDechet retourne 1. Ces tests sont donc OK, mais tous les autres ne le sont pas.

Retour dans Robot.php, je code l’utilisation de batterie supplémentaire :

public function batteriePourDechet(int $poidsDechet): int
{
    // Force supérieure ou égale au poids du déchet => 1% de batterie
    if ($this->force >= $poidsDechet) {
        return 1;
    }
    
    // Sinon, la différence * 2
    return ($poidsDechet - $this->force) * 2;
}

Ca m’a l’air pas mal tout ça ! Je relance mes tests :

Aïe encore un peu de rouge…

Bon, il ne manque plus que le dernier test, celui où je dépasse l’utilisation de la moitié de la batterie. Je termine ma méthode :

public function batteriePourDechet(int $poidsDechet): int
{
    // Force supérieure ou égale au poids du déchet => 1% de batterie
    if ($this->force >= $poidsDechet) {
        return 1;
    }
    
    // Sinon, la différence * 2
    $batterieAUtiliser = ($poidsDechet - $this->force) * 2;

    // Je ne dois pas dépasser la moitié de la batterie
    if ($batterieAUtiliser > ($this->batterie / 2)) {
        return 2;
    }
    
    return $batterieAUtiliser;
}

Et si je relance mes tests :

Que du vert !

Et voilà, la méthode est prête, le jeu de données passe complètement. Si je veux, je peux la peaufiner, utiliser des constantes de classe pour certains coefficients par exemple.

On a donc vu que le dataprovider permet de répéter un même test mais avec des données différentes. Si un test ne passe pas, le jeu de données qui a posé soucis est indiqué. Attention, le premier est le 0 et n’est pas indiqué.

Un second dataprovider

On traite maintenant la question de la recharge de la batterie, cf. énoncé :

Si la batterie de Wall-E passe sous les 20%, il doit aller se recharger. Cela lui coûte autant de batterie qu’il a de vitesse. Si sa vitesse est de 8, il utilise 8% de batterie pour aller se recharger. Il se recharge à 100% et utilise à nouveau de la batterie pour revenir, le même montant. Dans l’exemple, il revient avec 92% de batterie (100 – 8). Mais si la vitesse de Wall-E est supérieure à la batterie restante, alors il tombe en panne et le petit robot s’arrête.

On peut définir 5 jeux de données :

  • Le niveau de batterie n’implique pas d’aller se recharger
  • Le niveau de batterie n’implique pas d’aller se recharger (pile poil)
  • Le niveau de batterie implique d’aller se recharger et la vitesse me le permet
  • Le niveau de batterie impliquer d’aller se recharger et la vitesse me le permet pile poil
  • Le niveau de batterie implique d’aller se recharger mais la vitesse ne me le permet pas

Et créer le dataprovider associé :

public function providerNiveauDeBatterieApresRecharge(): array
{
    // Vitesse - batterie initiale - batterie après recharge
    return [
        [10, 50, 50], // Pas de recharge
        [10, 20, 20], // Pas de recharge
        [10, 18, 90], // Recharge OK
        [10, 11, 90], // Recharge OK tout juste
        [10, 10, 0] // Recharge KO, batterie tombe à 0
    ];
}

Et le test correspondant :

/**
 * @dataProvider providerNiveauDeBatterieApresRecharge
 */
public function test_recharge_batterie(int $vitesse, int $batterieInitiale, int $batterieFinale): void
{
    $walle = new Robot(
        force: 20,
        vitesse: $vitesse,
        batterie: $batterieInitiale
    );

    $walle->rechargeLaBatterie();

    $this->assertEquals(
        $walle->getBatterie(),
        $batterieFinale
    );
}

Et les 2 méthodes correspondantes dans Robot.php :

public function getBatterie(): int
{
    return $this->batterie;
}

public function rechargeLaBatterie(): void
{

    // Si la batterie de Wall-E passe sous les 20%, il doit aller se recharger
    if ($this->batterie < 20) {

        // Mais si la vitesse de Wall-E est supérieure à la batterie restante, alors il tombe en panne et le petit robot s'arrête.
        if ($this->batterie - $this->vitesse <= 0) {
            $this->batterie = 0;
            return;
        }

        //  Il se recharge à 100% et utilise à nouveau de la batterie pour revenir, le même montant (vitesse)
        $this->batterie = 100 - $this->vitesse;
    }
}

La première méthode est un getteur classique.

La seconde gère donc la recharge de la batterie. On a à nouveau repris le texte de l’énoncé en commentaires.

Programme principal

On a maintenant tous les morceaux de notre challenge, il nous reste à écrire le programme principal. Pour cela, on peut aller chercher des jeux de données avec l’API. Pour chaque jeu de données, la réponse est connue, on a donc tout ce qu’il faut pour écrire un test unitaire.

Jeu de données #1 :

$force = 20;
$vitesse = 5;
$batterie = 98;
$dechets = [8, 13, 12, 22, 32, 15, 7, 17, 5, 5, 7, 12, 12, 32, 10, 15, 13, 15, 19, 17];

$resultat = 29;

Jeu de données #2

$force = 13;
$vitesse = 15;
$batterie = 82;
$dechets = [15, 21, 20, 19, 14, 6, 9, 22, 14, 9, 10, 17, 33, 9, 8, 5, 22, 19, 23, 18];

$resultat = 'KO';

Les 2 tests correspondants :

public function test_dechets_wall_e_pas_ko(): void
{
    $force = 20;
    $vitesse = 5;
    $batterie = 98;
    $dechets = [8, 13, 12, 22, 32, 15, 7, 17, 5, 5, 7, 12, 12, 32, 10, 15, 13, 15, 19, 17];

    $resultat = 29;

    $walle = new Robot(
        force: $force,
        vitesse: $vitesse,
        batterie: $batterie
    );

    $walle->traiteDechets($dechets);

    $this->assertEquals(
        $walle->reponse(),
        $resultat
    );
}

public function test_dechets_wall_e_ko(): void
{
    $force = 13;
    $vitesse = 15;
    $batterie = 82;
    $dechets = [15, 21, 20, 19, 14, 6, 9, 22, 14, 9, 10, 17, 33, 9, 8, 5, 22, 19, 23, 18];

    $resultat = 'KO';

    $walle = new Robot(
        force: $force,
        vitesse: $vitesse,
        batterie: $batterie
    );

    $walle->traiteDechets($dechets);

    $this->assertEquals(
        $walle->reponse(),
        $resultat
    );
}

Une partie du code se répète, et j’aurais pu donc encore utiliser un dataprovider. Mais ce n’est pas obligatoire non plus. Pour des choses importantes, ça peut avoir du sens de séparer pour se répérer au nom du test et non au dataset.

Si j’execute mes tests, j’ai à nouveau des erreurs. Si tu as suivi jusque là, tu devrais avoir toi aussi :

Oh non du rouge !

On retourne dans Robot.php pour les méthodes traiteDechets() et reponse(). Quand les tests seront verts, c’est qu’on sera OK !

public function traiteDechets(array $dechets): void
{
    foreach ($dechets as $poidsDechet) {
        if ($this->batterie === 0) {
            break;
        }

        $this->batterie -= $this->batteriePourDechet($poidsDechet);

        $this->rechargeLaBatterie();
    }
}

public function reponse(): int|string
{
    if ($this->batterie === 0) {
        return 'KO';
    }

    return $this->batterie;
}

Tout est assez explicite, pas besoin de commentaire supplémentaire.

La méthode reponse a 2 types de retour possible, on peut donc déifnir les 2 depuis PHP 8 grâce à « |« .

Si je relance mes tests :

Du vert 😀

Conclusion

On a vu dans ce corrigé un process bien défini :

  • J’identifie les portions importantes de mon énoncé (ce qui est valable avec n’importe quelle spécification d’un projet web) et je les colle dans mon code pour le structurer. Si le code devient suffisamment explicite, je peux faire le ménage dans les commentaires.
  • J’initialise mon objet (les puristes du TDD pourront trouver à débattre ici 😉 )
  • Je définis mes jeux de données et je rédige mes tests
  • Je code mes méthodes jusqu’à ce que mes tests passent en vert. Dès que c’est vert, je m’arrête ou je rajoute des tests si je pense ne pas avoir couvert tous les cas.
  • Je recommence les 2 étapes précédentes jusqu’à résoudre tout mon problème / développer ma fonctionnalité / etc.

Ci-dessous la classe Robot complète, j’en ai profité pour refactoriser avec des constantes de classe. Les tests unitaires me permettent, en 1 commande de contrôler que mon code est toujours OK 🙂

Le code complet est également disponible sur Github.

Si tu veux aller + loin, tu peux retrouver le refactoring de ce code dans un autre corrigé.

<?php
declare(strict_types=1);

namespace Challenges\WALL_E;

final class Robot
{
    private const RATIO_BATTERIE_POUR_SOULEVER_DECHET = 2;
    private const BATTERIE_SI_PAS_POSSIBLE_DE_SOULEVER_DECHET = 2;
    private const BATTERIE_SI_POSSIBLE_DE_SOULEVER_DECHET = 1;
    private const BATTERIE_SEUIL_RECHARGE = 20;
    private const BATTERIE_MAX = 100;
    private const BATTERIE_MIN = 0;

    public function __construct( 
        // Permet de déterminer comment il porte les déchets
        private int $force,

        // Permet de savoir comment il se déplace
        private int $vitesse,

        // Un niveau de batterie, qui va évoluer dans le temps
        private int $batterie,
    )
    {
    }

    public function batteriePourDechet(int $poidsDechet): int
    {
        // Force supérieure ou égale au poids du déchet => 1% de batterie
        if ($this->force >= $poidsDechet) {
            return self::BATTERIE_SI_POSSIBLE_DE_SOULEVER_DECHET;
        }
        
        // Sinon, la différence * 2
        $batterieAUtiliser = ($poidsDechet - $this->force) * self::RATIO_BATTERIE_POUR_SOULEVER_DECHET;

        // Je ne dois pas dépasser la moitié de la batterie
        if ($batterieAUtiliser > ($this->batterie / 2)) {
            return self::BATTERIE_SI_PAS_POSSIBLE_DE_SOULEVER_DECHET;
        }
        
        return $batterieAUtiliser;
    }

    public function getBatterie(): int
    {
        return $this->batterie;
    }

    public function rechargeLaBatterie(): void
    {
        // Si la batterie de Wall-E passe sous les 20%, il doit aller se recharger
        if ($this->batterie < self::BATTERIE_SEUIL_RECHARGE) {

            // Mais si la vitesse de Wall-E est supérieure à la batterie restante, alors il tombe en panne et le petit robot s'arrête.
            if ($this->batterie - $this->vitesse <= self::BATTERIE_MIN) {
                $this->batterie = self::BATTERIE_MIN;
                return;
            }

            //  Il se recharge à 100% et utilise à nouveau de la batterie pour revenir, le même montant (vitesse)
            $this->batterie = self::BATTERIE_MAX - $this->vitesse;
        }
    }

    /**
     * @param int[] $dechets
     */
    public function traiteDechets(array $dechets): void
    {
        foreach ($dechets as $poidsDechet) {
            if ($this->batterie === self::BATTERIE_MIN) {
                break;
            }

            $this->batterie -= $this->batteriePourDechet($poidsDechet);

            $this->rechargeLaBatterie();
        }
    }

    public function reponse(): int|string
    {
        if ($this->batterie === self::BATTERIE_MIN) {
            return 'KO';
        }

        return $this->batterie;
    }
}

Qui a codé ce superbe contenu ?

Keep learning

Autres contenus à découvrir


Ta newsletter chaque mois

Corrigés, challenges, actualités, veille technique... aucun spam.