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...
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.
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.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
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
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.




