Pour qu'un code soit testable, il faut pouvoir contrôler ses entrées/sorties, rien de plus, rien de moins.
Mais qu'est ce que cela veut dire ?
Dans le cadre de la programmation orientée objet, cela veut dire qu'il faut, pour chaque méthode publique de la classe à tester, connaître le résultat désiré en fonction des arguments de la méthode.
Le corrollaire coule de source, il faut pouvoir contrôler finement les arguments qui sont passés à la méthode lors des tests qui la concerne pour pouvoir valider la valeur de retour de la méthode.
Or, pour réaliser cela, il n'y a qu'une seule solution, et cela tombe bien, il s'agit d'une bonne pratique : Il faut découpler son code, c'est à dire limiter au maximum les dépendances entre les classes.
Mais concrétement, qu'est ce que cela veut dire ?
Tout simplement que le mot-clef new
et tout autre méthode permettant d'instancier des objets, doivent être banni du code de vos méthodes.
En effet, vous ne pouvez pas contrôler les objets qui sont créés par vos méthodes.
Examinons le cas du code suivant :
class foo
{
protected $id = 0;
public function foo($id)
{
$this->id = $id;
}
public function load()
{
$dbClient = new dbClient();
return $dbClient->query('...');
}
}
Dans la méthode foo::load()
, un objet du type dbClient
est instancié.
Vous ne pouvez donc pas le contrôler puisque vous ne pouvez pas y accéder.
Vous ne pouvez pas définir des paramètres de connexion à la base de données invalides, vous ne pouvez pas simuler une impossibilité de connexion à la base, vous ne pouvez pas définir la base de données à utiliser, bref, votre périmétre de test est très limité, car vous ne pouvez tester que le cas le plus favorable, à la condition que les paramêtres par défaut du constructeur de la classe dbClient
permettent de se connecter à votre base de données de test (je passerai sur l'ignominie qui consiste à utiliser $GLOBALS
pour passer les paramêtres de connexion à dbClient
...
Le code de test ressemble alors à cela :
class testFoo extends test
{
protected function testFoo()
{
$foo = new foo(rand(1, PHP_INT_MAX));
$this->isResource($foo->load());
}
}
La solution ? Découplez le code de la manière suivante :
class foo
{
protected $id = 0;
public function foo($id)
{
$this->id = $id;
}
public function load(dbClient $dbClient = null)
{
if ($dbClient === null)
{
$dbClient = new dbClient();
}
return $dbClient->query('...');
}
}
De cette manière, vous pouvez contrôler totalement l'instance de dbClient
utilisée par votre code :
class testFoo extends test
{
...
protected function testFoo()
{
$dbClient = new dbClient(DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE);
$dbClient->disconnect();
$foo = new foo(rand(1, PHP_INT_MAX));
$this->false($dbClient->isConnected());
$this->error($foo->load($dbClient));
$dbClient->connect();
$this->true($dbClient->isConnected());$this->isResource($foo->load($dbClient));
}
...
}
Evidement, le passage d'arguments et la gestion de la valeur par défaut vont être pénalisant en terme de performances, mais de cette façon, vous disposez d'un périmétre de test beaucoup plus large, d'autant que vous pouvez utiliser les mock
pour simuler le comportement de votre instance de dbClient
.
Cerise sur le gateau, vos tests sont plus complets, et donc documentent beaucoup plus finement votre code puisque vous pouvez tester tous les cas limites auxquels vous penserez.
Il serait donc dommage de se priver de cela pour économiser quelques ressources matérielles.
4 réactions
1 De Palleas - 03/06/2009, 22:11
Effectivement, on oublie facilement l'aspect test unitaire quand on développe, c'est toujours bon d'avoir des piqures de rappel. Pour compléter ton article, j'aurais parlé de l'avantage d'utiliser des interfaces pour typer les paramètres d'entrées de certaines méthodes, de cette manière, le champs de test est encore plus vaste.
2 De mageekguy - 04/06/2009, 09:00
@palleas :
L'utilisation des interfaces ne s'imposent pas, loin de là, pour faire ce que tu suggères.
En effet, il suffit de typer les arguments des méthodes au niveau de la classe à tester pour obliger le développeur à passer le bon type d'argument lors de l'appel de la méthode.
Les interfaces sont plus un moyen de définir une API commune entre plusieurs classes qu'un système qui permet de définir un typage fort.
Elles sont de plus totalement indépendantes des tests puisqu'elles ne contiennent aucun code.
Enfin, une dernière remarque : pour ne pas oublier l'aspect test unitaire lorsqu'on développe, il suffit d'appliquer la méthode du
, soit en bon français, d'écrire le test avant d'écrire le code permettant au test de passer.Ceci dit, j'avoue que j'ai moi même du mal à m'y tenir :).
3 De Cédric - 12/06/2009, 09:18
L'intérêt d'être en TDD c'est qu'en écrivant le test avant le code on ne peut pas faire autrement qu'écrire du code testable. Cela n'enlève rien à l'article, l'injection de dépendance est une technique efficace et pas forcément évidente.
4 De mageekguy - 15/06/2009, 19:52
@Cédric:
C'est vrai et faux en même temps.
Les cas limites, notamment ceux générant des erreurs, ne sont pas forcément facile à tester, même en appliquant le TDD strictement, car il n'est pas forcément évident pour le développeur de créer le contexte de test dont il a besoin, comme par exemple ne pas arriver à créer un segment de mémoire partagé, une connexion à une base, etc.
Il est donc tentant, si l'on ne connait pas de techniques pour le faire, de laisser tout une partie du périmètre des tests inexplorés.