Créer un nouveau de scénario de test

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
require_once(dirname(__FILE__) . '/simpletest/autorun.php');

class TestOfLogging extends UnitTestCase {
    function testFirstLogMessagesCreatesFileIfNonexistent() {
    }
}
?>

Pas à pas, voici ce qu'il veut dire.

Le code dirname(__FILE__) s'assure juste que le chemin vers SimpleTest dépend bien du fichier courant.

Et donc qu'est-ce que ce fichier autorun.php ? Ce fichier fait ce qu'on attend de lui : il va charger les méthodes de UnitTestCase. Ensuite il collecte toutes les classes de test présentes dans le fichier courant et il les lancement automagiquement. Il y arrive en créant un point de sortie. On verra tout ça en détail quand on voudra modifier l'affichage.

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é par l'autorunner, elle cherche les méthodes commençant par "test" et les lancent. Toutes ces méthodes seront exécutées dans l'ordre de leur définition dans la classe. Notre seule méthode de test pour l'instant est appellée testCreatingNewFile() mais elle est encore vide.

Notre unique méthode test s'appelle testFirstLogMessagesCreatesFileIfNonexistent(). Et il n'y a rien dedans pour le moment.

Cette définition d'une méthode vide ne fait rien toute seule. Nous devons bien sûr lui ajouter du code. La classe UnitTestCase va typiquement généré des évènements de test quand elle sera exécutée et ces évènements seront ensuite envoyés à un rapporteur / observateur utilisant les méthodes héritées de UnitTestCase.

Et pour ajouter du code de test...

<?php
require_once(dirname(__FILE__) . '/simpletest/autorun.php');
require_once('../classes/log.php');

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

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.

Vous pourriez aussi vous dire que testFirstLogMessagesCreatesFileIfNonexistent est un nom de méthode fichtrement trop long. D'ordinaire ce serait exact, mais ici c'est une bonne chose. Nous n'aurons plus jamais à écrire ce nom, et nous n'aurons pas besoin non plus d'ajouter des commentaires ou des spécifications.

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
assertNotA($x, $t)Echoue sauf 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
assertWithinMargin($x, $y, $margin)Echoue sauf si $x et $y sont séparés par moins que $margin
assertOutsideMargin($x, $y, $margin)Echoue sauf si $x et $y sont suffisamment différents
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
assertSame($x, $y)Echoue sauf si $x et $y sont le même objet
assertClone($x, $y)Echoue sauf si $x et $y sont identiques, mais aussi des objets séparés
assertPattern($p, $x)Echoue sauf si l'expression rationnelle $p capture $x
assertNoPattern($p, $x)Echoue si l'expression rationnelle $p capture $x
assertNoErrors()Echoue si une erreur PHP arrive
expectError($e)Déclenche un échec si cette erreur n'arrive pas avant la fin du test
expectException($e)Déclenche un échec si cette exception n'est pas levé avant la fin du test

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 (ou "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. Votre fonctionnalité se crée en essayant de faire passer les tests qui la définissent.

Pensez-y comme d'une spécification éxécutable, créée en continue.

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 __construct($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: testFirstLogMessagesCreatesFileIfNonexistent->True assertion failed.
1/1 test cases complete. 0 passes, 1 fails and 0 exceptions.
"TestOfLogging" a échoué. 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 __construct() {
        parent::__construct('Log test');
    }

    function testFirstLogMessagesCreatesFileIfNonexistent() {
        @unlink(dirname(__FILE__) . '/../temp/test.log');
        $log = new Log(dirname(__FILE__) . '/../temp/test.log');
        $log->message('Should write this to a file');
        $this->assertTrue(file_exists(dirname(__FILE__) . '/../temp/test.log'));
    }
}

Ce qui donne...

Log test

Fail: testFirstLogMessagesCreatesFileIfNonexistent->File created.
1/1 test cases complete. 0 passes, 1 fails and 0 exceptions.
Si vous voulez changer le nom du test, alors il faudra le faire en changeant la sortie du rapporteur. Nous y reviendrons plus tard.

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 $path;
        
    function __construct($path) {
        $this->path = $path;
    }
        
    function message($message) {
        $file = fopen($this->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 test

1/1 test cases complete. 1 passes, 0 fails and 0 exceptions.
Ç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 testFirstLogMessagesCreatesFileIfNonexistent() {
        @unlink(dirname(__FILE__) . '/../temp/test.log');
        $log = new Log(dirname(__FILE__) . '/../temp/test.log');
        $this->assertFalse(file_exists(dirname(__FILE__) . '/../temp/test.log'));
        $log->message('Should write this to a file');
        $this->assertTrue(file_exists(dirname(__FILE__) . '/../temp/test.log'));

    }
}

...et découvrir que ça marche déjà...

TestOfLogging

1/1 test cases complete. 2 passes, 0 fails and 0 exceptions.
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.

Créer un nouveau scénario de test.
Le Développement Piloté par les Tests en PHP.
Les tests comme documentation est un des nombreux effets secondaires.
La FAQ de JUnit contient plein de conseils judicieux sur les tests.
Ensuite vient "comment grouper des scénarios de tests ensemble".
Vous aurez besoin du framework de test SimpleTest pour ces exemples.