Test Driven Developement – Kata : Word Wrap en PHP

word-wrap-php-kata
L’une des bonnes pratiques d’un développeur T.D.D. est d’approcher les problèmes via les cas les plus banales. Il approche les conditions d’erreur et les cas limites avant de s’attaquer le coeur du problème des le depart.
Cette technique est en fait une des règles à respecter si nous voulons réussir notre expérience en T.D.D. Dans ce post, via un exemple simple, nous allons voir comment on peut vite “se planter” quand nous ne la respectons pas, et Comment construire un algorithme se fait pas par pas.

Pour ce faire, nous allons faire la Kata du “problème de retours à la ligne dans un texte” (word wrap), tel que Uncle Bob l’aborde dans sa série Clean Code.

J’ai choisi d’utiliser PHP comme langage de programmation, car je trouve qu’il n’y a pas assez d’exemples de Katas sur le web comparé aux autres langages ( et aussi parce que c’est le langage que je connais 😉 ). Alors, je ne vous apprend rien, mais si vous comparez aux implémentations dans d’autres langages faiblement typés, il va y’avoir des choses que nous allons faire différemment, et des choses qui ne s’appliquent pas etc.
Le code est aussi disponible sur Github

Allons y donc! voici l’énoncé du Problème et les étapes du Kata :

  • Écrire une méthode string wrap($s, $width) qui prend deux paramètres : $s de type string, $width de type int et retourne un string.
  • Le texte en entrée $s est un texte formé de mots séparées par des espaces. Il n’y a pas de ponctuation, pas d’espaces multiples, pas de caractères spéciaux, uniquement des espaces simples.
  • Le texte en retour de la méthode wrap est le texte en entrée sauf qu’il contiendra des caractères de retour à la ligne (\n) à des positions stratégiques, de telle façon qu’il n’y a pas de ligne plus grande que $width. Nous devons  remplacer les espaces les plus appropriées par les retours à la ligne sauf si nous arrivons à un mot dont la taille dépasse $width. Dans ce cas, nous devons diviser ce mot.

L’approche sans petits pas

Dans cette section, nous allons voir l’impact de ne pas faire des petits pas. Pour quelqu’un qui n’est pas habitué au T.D.D., c’est la façon classique d’écrire des tests unitaires.
Le problème est très facile! Tant mieux! Commençons le travail tout de suite.
Ecrivons notre premier test, la chaine “word word” wrappée avec une taille de 4 devrait donner “word\nword” :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertEquals("word\nword", $this->wrap("word word", 4));
    }
}

Le test échoue maintenant, créons la méthode wrap, dans la même classe de test, et utilisons str_replace :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertEquals("word\nword", $this->wrap("word word", 4));
    }

    private function wrap($s, $width)
    {
        return str_replace(" ", "\n", $s);
    }
}

Fastoche .. ajoutons un deuxième test, la chaine “a dog” avec un width de 5 ne doit pas casser. :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        // test 1  
        $this->assertEquals("word\nword", $this->wrap("word word", 4));
        // test 2
        $this->assertEquals("a dog", $this->wrap("a dog", 5));
    }

    private function wrap($s, $width)
    {
        return str_replace(" ", "\n", $s);
    }
}

Le test échoue .. comment faire? une solution est de faire le remplacement uniquement si la taille de la chaine en entree est supérieure à $width :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        // test 1
        $this->assertEquals("word\nword", $this->wrap("word word", 4));
        // test 2
        $this->assertEquals("a dog", $this->wrap("a dog", 5));
    }

    private function wrap($s, $width)
    {
        $sLength = strlen($s);
        if ($sLength > $width) {
            return str_replace(" ", "\n", $s);
        }
        return $s;
    }
}

Le test Passe! Jusque là, c’est encore sous contrôle. Continuons avec le test suivant. La chaine “a dog with a bone” avec un width de 6 nous donne “a dog\nwith a\nbone” :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        // test 1  
        $this->assertEquals("word\nword", $this->wrap("word word", 4));
        // test 2
        $this->assertEquals("a dog", $this->wrap("a dog", 5));
        // test 3
        $this->assertEquals("a dog\nwith a\nbone", $this->wrap("a dog with a bone", 6));
    }

    private function wrap($s, $width)
    {
        $sLength = strlen($s);
        if ($sLength > $width) {
            return str_replace(" ", "\n", $s);
        }
        return $s;
    }
}

Le test échoue bien sur. Comment faire maintenant pour avancer sans réécrire la totalité de l’algorithme?
pensez y..
pensez y encore..
Avez vous une solution sans réécrire l’algo? Je ne pense pas qu’il y’en a une facile .. nous sommes bloqués.. nous sommes Coincés! C’est ce que Uncle Bob appelle : “Getting Stuck!”.
Choisir d’aller vite peut être une démarche.. et nous avons tous, nous, développeurs avec de l’égo de développeurs, tendance à pondre la solution la plus optimale dès le départ.
Il y’a une autre approche, l’approche T.D.D. :

La Kata Faite avec des petits Pas

Dans cette section, nous allons appliquer des règles de base du TDD, en y allant avec des baby steps! nous allons généraliser petit à petit le code en suivant la fameuse règle As the tests get more specific, the code gets more generic..

Allons y avec le test le plus simple et basique qui soit, soit une chaine nulle avec n’importe quelle taille, nous allons utiliser assertSame pour forcer le contrôle du type et de la valeur :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertSame("", $this->wrap(null, 1));
    }

    private function wrap($s, $width)
    {
        return null;
    }
}

Le test passe, ajoutons un autre test avec une chaine vide :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertEquals("", $this->wrap(null, 1));
        $this->assertEquals("", $this->wrap("", 1));

    }

    private function wrap($s, $width)
    {
        return "";
    }
}

Il passe aussi.
Le test suivant, super simple, avec un seul caractère et 1 comme taille :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertSame("", $this->wrap(null, 1));
        $this->assertSame("", $this->wrap("", 1));
        $this->assertSame("x", $this->wrap("x", 1));
    }

    private function wrap($s, $width)
    {
        return "";
    }
}

Le test échoue bien sur, nous pouvons le fixer comme suit :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertSame("", $this->wrap(null, 1));
        $this->assertSame("", $this->wrap("", 1));
        $this->assertSame("x", $this->wrap("x", 1));
    }

    private function wrap($s, $width)
    {
        return $s;
    }
}

Le test échoue, nous pouvons le fixer comme ceci :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertSame("", $this->wrap(null, 1));
        $this->assertSame("", $this->wrap("", 1));
        $this->assertSame("x", $this->wrap("x", 1));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        return $s;
    }
}

Il est temps de refactorer un peu ces tests! Quoi? Déja? Heu.. Oui déja! Les assertSame commencent à faire mal aux yeux. Si vous utilisez un outil comme PHPStorm, vous pouvez bénéficier de son excellente fonctionnalité de refactoring. Si vous utilisez vim, vous pouvez aussi utiliser ce plugin Vim PHP Refactoring Toolbox, qui n’es pas mal du tout!
Nous allons extraire le code de assertSame dans une méthode assertWraps qui prend la chaine $s, la taille $width et le résultat $expected.
Ce qui donne le code suivant :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        return $s;
    }

}

Les tests passent toujours, et le code est beaucoup plus lisible!!
Allons y avec le test suivant, deux caractères avec une taille de 1 doivent être splittés :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        return $s;
    }

}

On peut fixer ceci comme suit :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            return substr($s, 0, $width) . "\n" . substr($s, $width);
        }
    }

}

Et ca passe! Ok Ok, ne vous tannez pas! je sais qu’on y va vraiment petit à petit! Le truc ici est que nous ne voulons pas être coincés. continuons!
Continuons avec un test de division sur plusieurs lignes :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            return substr($s, 0, $width) . "\n" . substr($s, $width);
        }
    }

}

Le test échoue bien sur. Quel est le plus petit pas que nous pouvons faire pour fixer ce test? Un seul “keystroke”! ?

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            return substr($s, 0, $width) . "\n" . $this->wrap(substr($s, $width), $width);
        }
    }

}

Le voyez vous ? c’est l’appel récursif à la méthode wrap à la fin du code! Cela commence a devenir cool n’est ce pas?

Avez vous remarqué quelque chose? Nous n’avons pas testé avec un exemple contenant des espaces! allons y maintenant!

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");
        $this->assertWraps("x x", 1, "x\nx");

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            return substr($s, 0, $width) . "\n" . $this->wrap(substr($s, $width), $width);
        }
    }

}

Le test échoue. l’output de phpunit indique qu’il y’a un espace avant le deuxième x alors que nous avons asserté une chaine sans espace.

Allez, encore une fois, que devons nous faire pour fixer le problème en un “shot” ?

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");
        $this->assertWraps("x x", 1, "x\nx");

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            return substr($s, 0, $width) . "\n" . $this->wrap(trim(substr($s, $width)), $width);
        }
    }

}

HOHO! le voyez vous?! trim fait l’affaire! Nous avons fini! Non, nous n’avons pas fini encore! il reste le cas ou un mot dépasse la taille maximale. Dans ce cas nous devons insérer le \n avant ce mot-ci.
Faisons un test pour ca :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");
        $this->assertWraps("x x", 1, "x\nx");
        $this->assertWraps("x xx", 3, "x\nxx");

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            return substr($s, 0, $width) . "\n" . $this->wrap(trim(substr($s, $width)), $width);
        }
    }
}

Et ca échoue bien sur, pour le fixer, comme nous avons dit, il faut revenir au dernier espace et insérer le caractère a cette position. Nous pouvons utiliser strrpos pour ce faire.

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");
        $this->assertWraps("x x", 1, "x\nx");
        $this->assertWraps("x xx", 3, "x\nxx");

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            $index = $width + 1;
            $subString = substr($s, 0, $index);
            $breakPoint = strrpos($subString, " ");
            if ($breakPoint === false) {
                $breakPoint = $width;
            }
            return substr($s, 0, $breakPoint) . "\n" . $this->wrap(trim(substr($s, $breakPoint)), $width);
        }
    }
}

Voici ce que nous avons fait pour fixer le test :
– ajouter un breakPoint pour chercher la dernière position de l’espace dans la chaine.
– Si on ne trouve pas le breakPoint, nous utilisons tout simplement width.
– nous remplaçons width dans les soustractions substr.
C’est tout ce que nous avons fait et les tests Passent!
Nous pouvons appliquer un texte plus long, et vous pouvez essayer toutes les combinaisons possibles. En voici une version finale avec un test d’une chaine plus longue :

<?php
class WrapperTest extends PHPUnit_Framework_TestCase {
    public function testWrap()
    {
        $this->assertWraps(null, 1, "");
        $this->assertWraps("", 1, "");
        $this->assertWraps("x", 1, "x");
        $this->assertWraps("xx", 1, "x\nx");
        $this->assertWraps("xxx", 1, "x\nx\nx");
        $this->assertWraps("x x", 1, "x\nx");
        $this->assertWraps("x xx", 3, "x\nxx");
        $this->assertWraps(
            "four score and seven years ago our fathers brought forth upon this continent",
            7,
            "four\nscore\nand\nseven\nyears\nago our\nfathers\nbrought\nforth\nupon\nthis\ncontine\nnt"
        );

    }

    public function assertWraps($s, $width, $expected)
    {
        $this->assertSame($expected, $this->wrap($s, $width));
    }

    private function wrap($s, $width)
    {
        if ($s === null) {
            return "";
        }
        if (strlen($s) <= $width) {
            return $s;
        } else {
            $index = $width + 1;
            $subString = substr($s, 0, $index);
            $breakPoint = strrpos($subString, " ");
            if ($breakPoint === false) {
                $breakPoint = $width;
            }
            return substr($s, 0, $breakPoint) . "\n" . $this->wrap(trim(substr($s, $breakPoint)), $width);
        }
    }
}

Conclusion

Voyez vous comment les ajouts à l’algorithme sont simples et basiques? Nous n’avons pas eu a penser beaucoup entre chaque test / code.
Est ce que c’est juste moi qui trouve que c’est fascinant? Vraiment! Quand j’ai vu cette Kata présentée par Uncle Bob j’étais vraiment fasciné! Je trouve que cet exemple, aussi simple soit-il, expose comment y aller par petits incrément tout en spécifiant les tests et en généralisant le code. C’est un cas réél ou l’application du T.D.D. peut nous sauver du temps!.

Anis Berejeb

Anis est avant tout un passioné de l'agilité et du développement. Avec plus de 15 ans dans le domaine du développement web, son expertise combine des connaissances accrues dans l'ensemble des notions partant du développement logiciel jusqu'à l'organisation des équipes dans les environnements agiles à grande échelle.

You may also like...