Créer un nouveau de scénario de test

Cette page...

Si vous débutez avec les tests unitaires, il est recommandé d'essayer le code au fur et à mesure. Il n'y pas grand chose à taper et vous sentirez le rythme de la programmation pilotée par les tests.

Pour exécuter les exemples tels quels, vous aurez besoin de créer un nouveau répertoire et d'y installer trois dossiers : classes, tests et temp. Dézippez le framework SimpleTest dans le dossier tests et assurez vous que votre serveur web puisse atteindre ces endroits.

Un nouveau scénario de test

L'exemple dans l'introduction rapide comprenait les tests unitaires d'une simple classe de log. Dans ce tutorial à propos de Simple Test, je vais essayer de raconter toute l'histoire du développement de cette classe. Cette classe PHP est courte et simple : au cours de cette introduction, elle recevra beaucoup plus d'attention que dans le cadre d'un développement de production. Nous verrons que derrière son apparente simplicité se cachent des choix de conception étonnamment difficiles.

Peut-être que ces choix sont trop difficiles ? Plutôt que d'essayer de penser à tout en amont, je vais commencer par poser une exigence : nous voulons écrire des messages dans un fichier. Ces messages doivent être ajoutés en fin de fichier s'il existe. Plus tard nous aurons besoin de priorités, de filtres et d'autres choses encore, mais nous plaçons l'écriture dans un fichier au coeur de nos préoccupations. Nous ne penserons à rien d'autres par peur de confusion. OK, commençons par écrire un test...

<?php
    if (! defined('SIMPLE_TEST')) {
        define('SIMPLE_TEST', 'simpletest/');
    }
    require_once(SIMPLE_TEST . 'unit_tester.php');
    require_once(SIMPLE_TEST . 'reporter.php');

    class TestOfLogging extends UnitTestCase {
        function TestOfLogging() {
            $this->UnitTestCase();
        }
        function testCreatingNewFile() {
        }
    }
    
    $test = &new TestOfLogging();
    $test->run(new HtmlReporter());
?>
Pas à pas, voici ce qu'il veut dire.

La constante SIMPLE_TEST contient le chemin vers les classes de Simple Test à partir de ce fichier. Les classes pourraient être placées dans le path du fichier php.ini mais si vous êtes sur un serveur mutualisé, vous n'y aurez probablement pas accès. Pour que tout le monde soit content, le chemin est déclaré explicitement dans le script de test. Plus tard nous verrons comment tout finira au même endroit.

Demander la librairie unit_tester.php est assez évident mais qu'est-ce que ce fichier reporter.php ? Les librairies Simple Test sont une boîte à outils pour créer votre propre suite de tests standardisés. Elles peuvent être utilisées "telles quelles" sans problème, mais elles sont constituées de plusieurs éléments qui ont besoin d'être assemblés les uns aux autres. Le composant pour l'affichage est situé dans reporter.php. Probablement qu'un jour vous écrirez le vôtre : c'est pourquoi son inclusion est optionnelle. Simple Test contient une classe d'affichage, fonctionnelle mais basique : elle s'appelle HtmlReporter. Elle peut enregistrer des informations sur les tests : début, fin, erreur, réussite ou échec. Elle affiche cette information le plus rapidement possible au cas où le code de test planterait et masquerait le lieu de l'échec.

Les tests eux-mêmes sont rassemblés dans une classe de scénario de test. Cette dernière est typiquement une extension de la classe UnitTestCase. Quand le test est exécuté, elle cherche les méthodes commençant par "test" et les lancent. Notre seule méthode de test pour l'instant est appellée testCreatingNewFile() mais elle est encore vide.

Une méthode vide ne fait rien. Nous avons besoin d'y placer du code. La classe UnitTestCase génère des évènements de test à son exécution : ces évènements sont envoyés vers un observateur. La méthode UnitTestCase::run() lancent tous les tests de la classe.

Et pour ajouter du code de test...

<?php
    if (! defined('SIMPLE_TEST')) {
        define('SIMPLE_TEST', 'simpletest/');
    }
    require_once(SIMPLE_TEST . 'unit_tester.php');
    require_once(SIMPLE_TEST . 'reporter.php');
    require_once('../classes/log.php');

    class TestOfLogging extends UnitTestCase {
        function TestOfLogging() {
            $this->UnitTestCase();
        }
        function testCreatingNewFile() {
            @unlink('../temp/test.log');
            $log = new Log('../temp/test.log');
            $log->message('Should write this to a file');
            $this->assertTrue(file_exists('../temp/test.log'));
        }
    }
    
    $test = &new TestOfLogging();
    $test->run(new HtmlReporter());
?>

Vous pensez probablement que ça représente beaucoup de code pour un unique test et je suis d'accord avec vous. Ne vous inquiétez pas. Il s'agit d'un coût fixe et à partir de maintenant nous pouvons ajouter des tests : une ligne ou presque à chaque fois. Parfois moins en utilisant des artefacts de test que nous découvrirons plus tard.

Nous devons maintenant prendre nos premières décisions. Notre fichier de test s'appelle log_test.php (n'importe quel nom ferait l'affaire) : nous le plaçons dans un dossier appelé tests (partout ailleurs serait aussi bien). Notre fichier de code s'appelle log.php : c'est son contenu que nous allons tester. Je l'ai placé dans notre dossier classes : cela veut-il dire que nous construisons une classe ?

Pour cet exemple, la réponse est oui, mais le testeur unitaire n'est pas restreint aux tests de classe. C'est juste que le code orienté objet est plus facile à dépecer et à remodeler. Ce n'est pas par hasard si la conduite de tests fins via les tests unitaires est apparue au sein de la communauté OO.

Le test en lui-même est minimal. Tout d'abord il élimine tout autre fichier de test qui serait encore présent. Les décisions de conception arrivent ensuite en rafale. Notre classe s'appelle Log : elle passe le chemin du fichier au constructeur. Nous créons le log et nous lui envoyons aussitôt un message en utilisant la méthode message(). L'originalité dans le nommage n'est pas une caractéristique désirable chez un développeur informatique : c'est triste mais c'est comme ça.

La plus petite unité d'un test mmm... heu... unitaire est l'assertion. Ici nous voulons nous assurer que le fichier log auquel nous venons d'envoyer un message a bel et bien été créé. UnitTestCase::assertTrue() enverra un évènement réussite si la condition évaluée est vraie ou un échec dans le cas contraire. Nous pouvons avoir un ensemble d'assertions différentes et encore plus si nous étendons nos scénarios de test classique. Voici la liste...
assertTrue($x) Echoue si $x est faux
assertFalse($x) Echoue si $x est vrai
assertNull($x) Echoue si $x est initialisé
assertNotNull($x) Echoue si $x n'est pas initialisé
assertIsA($x, $t) Echoue si $x n'est pas de la classe ou du type $t
assertEqual($x, $y) Echoue si $x == $y est faux
assertNotEqual($x, $y) Echoue si $x == $y est vrai
assertIdentical($x, $y) Echoue si $x === $y est faux
assertNotIdentical($x, $y) Echoue si $x === $y est vrai
assertReference($x, $y) Echoue sauf si $x et $y sont la même variable
assertCopy($x, $y) Echoue si $x et $y sont la même variable
assertWantedPattern($p, $x) Echoue sauf si l'expression rationnelle $p capture $x
assertNoUnwantedPattern($p, $x) Echoue si l'expression rationnelle $p capture $x
assertNoErrors() Echoue si une erreur PHP arrive
assertError($x) Echoue si aucune erreur ou message incorrect de PHP n'arrive

Nous sommes désormais prêt à lancer notre script de test en le passant dans le navigateur. Qu'est-ce qui devrait arriver ? Il devrait planter...

Fatal error: Failed opening required '../classes/log.php' (include_path='') in /home/marcus/projects/lastcraft/tutorial_tests/Log/tests/log_test.php on line 7
La raison ? Nous n'avons pas encore créé log.php.

Mais attendez une minute, c'est idiot ! Ne me dites pas qu'il faut créer un test sans écrire le code à tester auparavant...

Développement piloté par les tests

Co-inventeur de l'Extreme Programming, Kent Beck a lancé un autre manifeste. Le livre est appelé Test Driven Development (Développement Piloté par les Tests) ou TDD et élève les tests unitaires à une position élevée de la conception. En quelques mots, vous écrivez d'abord un petit test et seulement ensuite le code qui passe ce test. N'importe quel bout de code. Juste pour qu'il passe.

Vous écrivez un autre test et puis de nouveau du code qui passe. Vous aurez alors un peu de duplication et généralement du code pas très propre. Vous remaniez (factorisez) ce code-là en vous assurant que les tests continuent à passer : vous ne pouvez rien casser. Une fois que le code est le plus propre possible vous êtes prêt à ajouter des nouvelles fonctionnalités. Il suffit juste de rajouter des nouveaux tests et de recommencer le cycle une nouvelle fois.

Il s'agit d'une approche assez radicale et j'ai parfois l'impression qu'elle est incomplète. Mais il s'agit d'un moyen efficace pour expliquer un testeur unitaire ! Il se trouve que nous avons un test qui échoue, pour ne pas dire qu'il plante : l'heure est venue d'écrire du code dans log.php...

<?php
    
    class Log {
        
        function Log($file_path) {
        }
        
        function message($message) {
        }
    }
?>
Il s'agit là du minimum que nous puissions faire pour éviter une erreur fatale de PHP. Et maintenant la réponse devient...

testoflogging

Fail: testcreatingnewfile->True assertion failed.
1/1 test cases complete. 0 passes and 1 fails.
"testoflogging" a échoué. Parmi les défauts de PHP on trouve cette fâcheuse tendance à transformer intérieurement les noms de classes et de méthodes en minuscules. SimpleTest utilise ces noms par défaut pour décrire les tests mais nous pouvons les remplacer par nos propres noms.
class TestOfLogging extends UnitTestCase {
    function TestOfLogging() {
        $this->UnitTestCase('Log class test');
    }
    function testCreatingNewFile() {
        @unlink('../temp/test.log');
        $log = new Log('../temp/test.log');
        $log->message('Should write this to a file');
        $this->assertTrue(file_exists('../temp/test.log'), 'File created');
    }
}
Ce qui donne...

Log class test

Fail: testcreatingnewfile->File created.
1/1 test cases complete. 0 passes and 1 fails.
Par contre pour le nom des méthodes il n'y a rien à faire, désolé.

Les messages d'un test comme ceux-ci ressemblent à bien des égards à des commentaires de code. Certains ne jurent que par eux, d'autres au contraire les bannissent purement et simplement en les considérant aussi encombrants qu'inutiles. Pour ma part, je me situe quelque part au milieu.

Pour que le test passe, nous pourrions nous contenter de créer le fichier dans le constructeur de Log. Cette technique "en faisant semblant" est très utile pour vérifier que le test fonctionne pendant les passages difficiles. Elle le devient encore plus si vous sortez d'un passage avec des tests ayant échoués et que vous voulez juste vérifier de ne pas avoir oublié un truc bête. Nous n'allons pas aussi lentement donc...

<?php   
    class Log {
        var $_file_path;
        
        function Log($file_path) {
            $this->_file_path = $file_path;
        }
        
        function message($message) {
            $file = fopen($this->_file_path, 'a');
            fwrite($file, $message . "\n");
            fclose($file);
        }
    }
?>
Au total, pas moins de 4 échecs ont été nécessaire pour passer à l'étape suivante. Je n'avais pas créé le répertoire temporaire, je ne lui avais pas donné les droits d'écriture, j'avais une coquille et je n'avais pas non plus ajouté ce nouveau répertoire dans CVS. N'importe laquelle de ces erreurs aurait pu m'occuper pendant plusieurs heures si elle était apparue plus tard mais c'est bien pour ces cas là qu'on teste. Avec les corrections adéquates, ça donne...

Log class test

1/1 test cases complete. 1 passes and 0 fails.
Ça marche!

Peut-être n'aimez-vous pas le style plutôt minimal de l'affichage. Les succès ne sont pas montrés par défaut puisque généralement vous n'avez pas besoin de plus d'information quand vous comprenez effectivement ce qui se passe. Dans le cas contraire, pensez à écrire d'autres tests.

D'accord, c'est assez strict. Si vous voulez aussi voir les succès alors vous pouvez créer une sous-classe de HtmlReporter et l'utiliser pour les tests. Même moi j'aime bien ce confort parfois.

Les tests comme documentation

Il y a une nuance ici. Nous ne voulons pas créer de fichier avant d'avoir effectivement envoyé de message. Plutôt que d'y réfléchir trop longtemps, nous allons juste ajouter un test pour ça.

class TestOfLogging extends UnitTestCase {
    function TestOfLogging() {
        $this->UnitTestCase('Log class test');
    }
    function testCreatingNewFile() {
        @unlink('../temp/test.log');
        $log = new Log('../temp/test.log');
        $this->assertFalse(file_exists('../temp/test.log'), 'No file created before first message');
        $log->message('Should write this to a file');
        $this->assertTrue(file_exists('../temp/test.log'), 'File created');
    }
}
...et découvrir que ça marche déjà...

Log class test

1/1 test cases complete. 2 passes and 0 fails.
En fait je savais que ça allait être le cas. J'ajoute ce test de confirmation tout d'abord pour garder l'esprit tranquille, mais aussi pour documenter ce comportement. Ce petit test supplémentaire dans son contexte en dit plus long qu'un scénario utilisateur d'une douzaine de lignes ou qu'un diagramme UML complet. Que la suite de tests devienne une source de documentation est un effet secondaire assez agréable.

Devrions-nous supprimer le fichier temporaire à la fin du test ? Par habitude, je le fais une fois que j'en ai terminé avec la méthode de test et qu'elle marche. Je n'ai pas envie de valider du code qui laisse des restes de fichiers de test traîner après un test. Mais je ne le fais pas non plus pendant que j'écris le code. Peut-être devrais-je, mais parfois j'ai besoin de voir ce qui se passe : on retrouve cet aspect confort évoqué plus haut.

Dans un véritable projet, nous avons habituellement plus qu'un unique scénario de test : c'est pourquoi nous allons regarder comment grouper des tests dans des suites de tests.

Pour aller plus loin...