Test Driven Development (TDD) en PHP en s’appuyant sur les données de l’énoncé

On prend les éléments de l’énoncé, pas à pas, pour construire des tests unitaires et verrouiller notre code.

→ Challenge Correction: Greenoid #7 - A message to pass on

Septième corrigé des challenges de l’histoire originale Greenoïd, créée à l’occasion de la Battle Dev Thales 2024.

Il était question dans ce challenge d’appliquer plusieurs opérations sur des chaines de caractères : les passer en binaire, les composer, les repasser en base 10, mettre en place des correspondances avec des caractères, etc. Il y avait une complexité algorithmique sur le fait de devoir « composer » les clés une à une sans doublon, sinon il s’agissait d’opérations assez abordables mais qu’il convenait de bien structurer pour les réaliser pas à pas correctement.

L’énoncé présentait donc les étapes à réaliser, avec à chaque fois des exemples de données. Je propose dans ce corrigé de s’appuyer sur ces exemples de données pour résoudre ce challenge en TDD, Test Driven Development, c’est à dire d’écrire d’abord les tests puis d’écrire le code qui permet de répondre aux tests. Pour cela, on travaillera en PHP avec le framework Pest. Mais la logique est la même peu importe le langage ou le framework de tests unitaires utilisés.

Au programme :

Tests unitaires pour le passage des clés en binaire sur 8 bits

La première étape consistait en transformer une clé de type « 383jh4bn5s525 » en une valeur binaire sur 8 bits, c’est à dire composée de 8 caractères (0 ou 1). Pour cela, 4 étapes :

  • Transformer chaque caractère en une valeur numérique
  • Faire la somme de ces valeurs
  • Passer en binaire
  • Passer en 8 bits si on n’y était pas déjà, c’est à dire rajouter autant de zéros que nécessaire au début (pour ne pas altérer la valeur)

La première étape « Transformer chaque caractère en une valeur numérique » pouvait être découpée en 2 tests unitaires :

test('Chaque caractère a une valeur (chiffre)', function() {

    // un “0” vaut 0, un “1” vaut 1, un “2” vaut 2, un “3”, vaut 3
    expect(CommunicationNetwork::charToNumber('0'))->toBe(0);
    expect(CommunicationNetwork::charToNumber('1'))->toBe(1);
    expect(CommunicationNetwork::charToNumber('2'))->toBe(2);
    expect(CommunicationNetwork::charToNumber('3'))->toBe(3);
    // Jusque 9
    expect(CommunicationNetwork::charToNumber('9'))->toBe(9);
});

test('Chaque caractère a une valeur (lettre)', function() {

    // un “a” vaut 10, un “b” vaut 11, un “c” vaut 12, etc.
    expect(CommunicationNetwork::charToNumber('a'))->toBe(10);
    expect(CommunicationNetwork::charToNumber('b'))->toBe(11);
    expect(CommunicationNetwork::charToNumber('c'))->toBe(12);
});

Je reprends dans les commentaires des tests unitaires les éléments de l’énoncé. Je me suis permis ici de grouper plusieurs assertions par test, ce qui n’est pas forcément une bonne pratique.

Si on exécute ces tests, forcément, ils renvoient des erreurs, puisque la classe CommunicationNetwork n’existe pas encore. Mais c’est le principe du TDD : les tests d’abord, le code ensuite.

On crée donc la classe CommunicationNetwork avec la première méthode « nue » :

class CommunicationNetwork
{

    public static function charToNumber(string $char): int
    {
        return 0;
    }
}

Si je relance les tests, il n’y a plus d’erreur technique, mais les tests sont toujours échoués.

Voici une version possible de la méthode charToNumber :

public static function charToNumber(string $char): int
{
    if (is_numeric($char)) {
        return (int) $char;
    }

    $alphabet = range('a', 'z');

    return array_search($char, $alphabet) + 10;
}

Un peu d’explications :

  • Si le caractère « est numérique », c’est à dire s’il s’agit d’un chiffre, alors je renvoie ce chiffre, typé préalablement en entier.
  • Sinon, je crée un alphabet, je recherche la position du caractère dans l’alphabet à laquelle j’ajoute 10.

Il y a bien sûr d’autres façons de faire, mais avec ce code, mes tests passent au vert, je peux continuer !

Maintenant j’ai dans l’énoncé l’exemple d’une clé transformée, je peux donc créer un test associé :

test('Passage des clés binaires en 8 bits', function() {
    expect(CommunicationNetwork::keyTo8Bits('ab34'))->toBe('00011100');
});

Je n’ai qu’un exemple, mais on va s’en contenter. Comme précédemment, je crée une méthode qui permet de valider ce test :

public static function keyTo8Bits(string $key): string
{
    $value = 0;

    foreach (str_split($key) as $char) {
        $value += self::charToNumber($char);
    }

    $value = decbin($value);

    return str_pad($value, 8, '0', STR_PAD_LEFT);
}

Un peu d’explications :

  • Je démarre $value à 0 car je vais faire une somme.
  • Je parcours chaque caractère de ma clé, que je convertie en nombre grâce à ma méthode précédente, et que j’incrémente à $value.
  • J’utilise ensuite la fonction native decbin pour passer d’une valeur décimale à une valeur binaire.
  • Enfin la fonction str_pad me permet de m’assurer que $value fait bien 8 caractères, et que si ce n’est pas le cas, d’y rajouter des « 0 » par la gauche (STR_PAD_LEFT)

Et hop, test validé !

Tests unitaires pour la composition des clés et application avec 2 boucles for

L’énoncé donnait un exemple de composition :

  • clé 1 : 1001
  • clé 2 : 1100
  • composition : 1010

On pouvait donc écrire le test unitaire associé :

test('Composition de deux clés', function() {
    expect(CommunicationNetwork::composition('1001', '1100'))->toBe('1010');
});

Encore une fois, un seul exemple, mais qu’on peut « inverser » et qui doit toujours donner la même valeur. Pour avoir alors 2 assertions :

test('Composition de deux clés', function() {
    expect(CommunicationNetwork::composition('1001', '1100'))->toBe('1010');
    expect(CommunicationNetwork::composition('1100', '1001'))->toBe('1010');
});

Et la méthode « composition » :

public static function composition(string $key1, string $key2): string
{
    $composition = '';

    $length = strlen($key1);

    for ($i = 0; $i < $length; $i++) {
        $composition .= $key1[$i] === $key2[$i] ? '1' : '0';
    }

    return $composition;
}

Un peu d’explications :

  • Je commence avec une chaine de caractères $composition vide, car je vais concaténer sur cette chaine au fil de ma méthode
  • Je récupère la longueur de la première chaine. J’aurais pu m’assurer que les 2 chaines avaient une longueur identiques, et déclencher une erreur par exemple si ce n’était pas le cas.
  • J’incrémente un index $i dans une boucle pour pouvoir atteindre les caractères de $key1 et de $key2 en même temps.
  • J’utilise un ternaire pour condenser un peu mon code. Je vais donc concaténer 0 ou 1 selon l’égalité entre le caractère de $key1 et le caractère de $key2 d’index $i.

La composition de toutes les clés

L’énoncé indiquait : « Il faut donc déterminer toutes les compositions possibles, sans doublon. Si tu as analysé la clé 3 avec la clé 5, plus besoin d’analyser la clé 5 avec la clé 3.« 

Pour réussir cela, on pouvait imbriquer 2 boucles for l’une dans l’autre, en utilisant l’index de la première boucle comme point de départ de la seconde :

$compositions = [];
for ($i = 0; $i < 8; $i++) {
    for ($j = $i + 1; $j < 8; $j++) {
        // Ajout de la combinaison unique (sans doublon)
        $compositions[] = CommunicationNetwork::composition($binaryKeys[$i], $binaryKeys[$j]);
    }
}

La subtilité se trouve dans l’initialisation de $j avec $j = $i + 1; on utilise $i. La première boucle a 8 itérations, lorsque la seconde a un nombre d’itérations qui diminue à chaque fois, d’abord 7, puis 6, puis 5, etc. Permettant d’éviter le traitement de doublons.

Repassage en base 10, tri et correspondances des caractères, le tout avec array_map

Pour le passage en base 10 et le tri, PHP propose des fonctions natives :

  • bindec (l’inverse de decbin vue + haut)
  • sort pour trier

On va se concentrer alors sur le passage d’un nombre vers le caractère de la clé finale attendue. Avec les données de l’énoncé, on pouvait créer ce test avec 4 assertions :

test('Base 10 vers caractère', function() {

    expect(CommunicationNetwork::toChar(196))->toBe('g');
    expect(CommunicationNetwork::toChar(144))->toBe('0');
    expect(CommunicationNetwork::toChar(178))->toBe('y');
    expect(CommunicationNetwork::toChar(243))->toBe('r');
});

Et la méthode « toChar » :

public static function toChar(int $number): string
{
    $position = $number % 36;

    $table = array_merge(range(0, 9), range('a', 'z'));

    return (string) $table[$position];
}

Un peu d’explications :

  • L’opérateur « % » représente le modulo, c’est à dire le reste entier de la division euclidienne. $position va donc contenir obligatoirement une valeur comprise entre 0 et 35.
  • Je crée un tableau qui contient les valeurs de 0 à 9 et les valeurs de « a » à « z »
  • Je retourne l’élément de ce tableau qui se trouve à $position. Je le type en string pour le cas où je suis sur un chiffre.

Utilisation de array_map en PHP

On peut utiliser array_map de 2 façons différentes pour réaliser nos différentes opérations. Considérons que nos compositions sont dans $compositions :

// Passage en base 10
$compositions = array_map('bindec', $compositions);

// Tri
sort($compositions);

// Passage en caractère
$compositions = array_map([CommunicationNetwork::class, 'toChar'], $compositions);

Un peu d’explications :

  • Pour le premier array_map, je donne simplement le nom de la fonction
  • Pour le second, je passe un tableau qui contient d’abord le nom de la casse, puis la méthode concernée

Conclusion et solution complète

Dans le contexte de la Battle Dev, l’heure n’était pas écrire des tests unitaires, il fallait aller vite ! Mais dans un autre contexte, pour des challenges qui présentent à la fois des étapes bien définies, et des exemples de données (entrées / sorties), l’écriture de tests unitaires est un bon moyen de bien structurer son code et d’apprendre à le découper de la bonne façon. Bien souvent, un code testable facilement est un code qui est bien structuré.

N’hésite pas à repartir d’un autre challenge de code et d’appliquer à nouveau cette méthodologie !

Et voici la solution complète, en 3 parties :

Les tests unitaires

<?php
declare(strict_types=1);

use Challenges\GREENOID_7\CommunicationNetwork;

test('Chaque caractère a une valeur (chiffre)', function() {

    // un “0” vaut 0, un “1” vaut 1, un “2” vaut 2, un “3”, vaut 3
    expect(CommunicationNetwork::charToNumber('0'))->toBe(0);
    expect(CommunicationNetwork::charToNumber('1'))->toBe(1);
    expect(CommunicationNetwork::charToNumber('2'))->toBe(2);
    expect(CommunicationNetwork::charToNumber('3'))->toBe(3);
    // Jusque 9
    expect(CommunicationNetwork::charToNumber('9'))->toBe(9);
});

test('Chaque caractère a une valeur (lettre)', function() {

    // un “a” vaut 10, un “b” vaut 11, un “c” vaut 12, etc.
    expect(CommunicationNetwork::charToNumber('a'))->toBe(10);
    expect(CommunicationNetwork::charToNumber('b'))->toBe(11);
    expect(CommunicationNetwork::charToNumber('c'))->toBe(12);
});

test('Passage des clés binaires en 8 bits', function() {
    expect(CommunicationNetwork::keyTo8Bits('ab34'))->toBe('00011100');
});

test('Composition de deux clés', function() {
    expect(CommunicationNetwork::composition('1001', '1100'))->toBe('1010');
    expect(CommunicationNetwork::composition('1100', '1001'))->toBe('1010');
});

test('Base 10 vers caractère', function() {

    expect(CommunicationNetwork::toChar(196))->toBe('g');
    expect(CommunicationNetwork::toChar(144))->toBe('0');
    expect(CommunicationNetwork::toChar(178))->toBe('y');
    expect(CommunicationNetwork::toChar(243))->toBe('r');
});

La preuve en image :

La classe CommunicationNetwork

<?php
declare(strict_types=1);

namespace Challenges\GREENOID_7;

class CommunicationNetwork
{

    public static function charToNumber(string $char): int
    {
        if (is_numeric($char)) {
            return (int) $char;
        }

        $alphabet = range('a', 'z');

        return array_search($char, $alphabet) + 10;
    }
    
    public static function keyTo8Bits(string $key): string
    {
        $value = 0;

        foreach (str_split($key) as $char) {
            $value += self::charToNumber($char);
        }

        $value = decbin($value);

        return str_pad($value, 8, '0', STR_PAD_LEFT);
    }

    public static function composition(string $key1, string $key2): string
    {
        $composition = '';

        $length = strlen($key1);

        for ($i = 0; $i < $length; $i++) {
            $composition .= $key1[$i] === $key2[$i] ? '1' : '0';
        }

        return $composition;
    }

    public static function toChar(int $number): string
    {
        $position = $number % 36;

        $table = array_merge(range(0, 9), range('a', 'z'));

        return (string) $table[$position];
    }
}

Le programme principal

<?php
declare(strict_types=1);

namespace Challenges\GREENOID_7;

use Tainix\Html;

// Données
$keys = ['383jh4bn5s525', '91o27x84if8', '14vo45o28l', '8w6nkr2g899g5', '48lcj889m1', 'ac3x2f21i3c7', 'h1n4749bw254', 'ttp994j27267'];

// Résolution

// Passage des clés en binaire sur 8 bits
$binaryKeys = [];
foreach ($keys as $key) {
    $binaryKeys[] = CommunicationNetwork::keyTo8Bits($key);
}

// Compositions
$compositions = [];
for ($i = 0; $i < 8; $i++) {
    for ($j = $i + 1; $j < 8; $j++) {
        // Ajout de la combinaison unique (sans doublon)
        $compositions[] = CommunicationNetwork::composition($binaryKeys[$i], $binaryKeys[$j]);
    }
}

// Passage en base 10
$compositions = array_map('bindec', $compositions);

// Tri
sort($compositions);

// Passage en caractère
$compositions = array_map([CommunicationNetwork::class, 'toChar'], $compositions);

// Solution
echo implode($compositions);


Qui a codé ce superbe contenu ?

Keep learning

Other content to discover