Documentation sur les objets fantaisie partiels

Cette page...

Un objet fantaisie partiel n'est ni plus ni moins qu'un modèle de conception pour soulager un problème spécifique du test avec des objets fantaisie, celui de placer des objets fantaisie dans des coins serrés. Il s'agit d'un outil assez limité et peut-être même une idée pas si bonne que ça. Elle est incluse dans SimpleTest pour la simple raison que je l'ai trouvée utile à plus d'une occasion et qu'elle m'a épargnée pas mal de travail dans ces moments-là.

Le problème de l'injection dans un objet fantaisie

Quand un objet en utilise un autre il est très simple d'y faire circuler une version fantaisie déjà prête avec ses attentes. Les choses deviennent un peu plus délicates si un objet en crée un autre et que le créateur est celui que l'on souhaite tester. Cela revient à dire que l'objet créé devrait être une fantaisie, mais nous pouvons difficilement dire à notre classe sous test de créer un objet fantaisie plutôt qu'un "vrai" objet. La classe testée ne sait même pas qu'elle travaille dans un environnement de test.

Par exemple, supposons que nous sommes en train de construire un client telnet et qu'il a besoin de créer une socket réseau pour envoyer ses messages. La méthode de connexion pourrait ressemble à quelque chose comme...

<?php
    require_once('socket.php');
    
    class Telnet {
        ...
        function &connect($ip, $port, $username, $password) {
            $socket = &new Socket($ip, $port);
            $socket->read( ... );
            ...
        }
    }
?>
Nous voudrions vraiment avoir une version fantaisie de l'objet socket, que pouvons nous faire ?

La première solution est de passer la socket en tant que paramètre, ce qui force la création au niveau inférieur. Charger le client de cette tâche est effectivement une bonne approche si c'est possible et devrait conduire à un remaniement -- de la création à partir de l'action. En fait, c'est là une des manières avec lesquels tester en s'appuyant sur des objets fantaisie vous force à coder des solutions plus resserrées sur leur objectif. Ils améliorent votre programmation.

Voici ce que ça devrait être...

<?php
    require_once('socket.php');
    
    class Telnet {
        ...
        function &connect(&$socket, $username, $password) {
            $socket->read( ... );
            ...
        }
    }
?>
Sous-entendu, votre code de test est typique d'un cas de test avec un objet fantaisie.
class TelnetTest extends UnitTestCase {
    ...
    function testConnection() {
        $socket = &new MockSocket($this);
        ...
        $telnet = &new Telnet();
        $telnet->connect($socket, 'Me', 'Secret');
        ...
    }
}
C'est assez évident que vous ne pouvez descendre que d'un niveau. Vous ne voudriez pas que votre application de haut niveau crée tous les fichiers de bas niveau, sockets et autres connexions à la base de données dont elle aurait besoin. Elle ne connaîtrait pas les paramètres du constructeur de toute façon.

La solution suivante est de passer l'objet créé sous la forme d'un paramètre optionnel...

<?php
    require_once('socket.php');
    
    class Telnet {
        ...
        function &connect($ip, $port, $username, $password, $socket = false) {
            if (!$socket) {
                $socket = &new Socket($ip, $port);
            }
            $socket->read( ... );
            ...
            return $socket;
        }
    }
?>
Pour une solution rapide, c'est généralement suffisant. Ensuite le test est très similaire : comme si le paramètre était transmis formellement...
class TelnetTest extends UnitTestCase {
    ...
    function testConnection() {
        $socket = &new MockSocket($this);
        ...
        $telnet = &new Telnet();
        $telnet->connect('127.0.0.1', 21, 'Me', 'Secret', &$socket);
        ...
    }
}
Le problème de cette approche tient dans son manque de netteté. Il y a du code de test dans la classe principale et aussi des paramètres transmis dans le scénario de test qui ne sont jamais utilisés. Il s'agit là d'une approche rapide et sale, mais qui ne reste pas moins efficace dans la plupart des situations.

Une autre solution encore est de laisser un objet fabrique s'occuper de la création...

<?php
    require_once('socket.php');
    
    class Telnet {
        function Telnet(&$network) {
            $this->_network = &$network;
        }
        ...
        function &connect($ip, $port, $username, $password) {
            $socket = &$this->_network->createSocket($ip, $port);
            $socket->read( ... );
            ...
            return $socket;
        }
    }
?>
Il s'agit là probablement de la réponse la plus travaillée étant donné que la création est maintenant située dans une petite classe spécialisée. La fabrique réseau peut être testée séparément et utilisée en tant que fantaisie quand nous testons la classe telnet...
class TelnetTest extends UnitTestCase {
    ...
    function testConnection() {
        $socket = &new MockSocket($this);
        ...
        $network = &new MockNetwork($this);
        $network->setReturnReference('createSocket', $socket);
        $telnet = &new Telnet($network);
        $telnet->connect('127.0.0.1', 21, 'Me', 'Secret');
        ...
    }
}
Le problème reste que nous ajoutons beaucoup de classes à la bibliothèque. Et aussi que nous utilisons beaucoup de fabriques ce qui rend notre code un peu moins intuitif. La solution la plus flexible, mais aussi la plus complexe.

Peut-on trouver un juste milieu ?

Méthode fabrique protégée

Il existe une technique pour palier à ce problème sans créer de nouvelle classe dans l'application; par contre elle induit la création d'une sous-classe au moment du test. Premièrement nous déplaçons la création de la socket dans sa propre méthode...

<?php
    require_once('socket.php');
    
    class Telnet {
        ...
        function &connect($ip, $port, $username, $password) {
            $socket = &$this->_createSocket($ip, $port);
            $socket->read( ... );
            ...
        }
        
        function &_createSocket($ip, $port) {
            return new Socket($ip, $port);
        }
    }
?>
Il s'agit là de la seule modification dans le code de l'application.

Pour le scénario de test, nous devons créer une sous-classe de manière à intercepter la création de la socket...

class TelnetTestVersion extends Telnet {
    var $_mock;
    
    function TelnetTestVersion(&$mock) {
        $this->_mock = &$mock;
        $this->Telnet();
    }
    
    function &_createSocket() {
        return $this->_mock;
    }
}
Ici j'ai déplacé la fantaisie dans le constructeur, mais un setter aurait fonctionné tout aussi bien. Notez bien que la fantaisie est placée dans une variable d'objet avant que le constructeur ne soit attaché. C'est nécessaire dans le cas où le constructeur appelle connect(). Autrement il pourrait donner un valeur nulle à partir de _createSocket().

Après la réalisation de tout ce travail supplémentaire le scénario de test est assez simple. Nous avons juste besoin de tester notre nouvelle classe à la place...

class TelnetTest extends UnitTestCase {
    ...
    function testConnection() {
        $socket = &new MockSocket($this);
        ...
        $telnet = &new TelnetTestVersion($socket);
        $telnet->connect('127.0.0.1', 21, 'Me', 'Secret');
        ...
    }
}
Cette nouvelle classe est très simple bien sûr. Elle ne fait qu'initier une valeur renvoyée, à la manière d'une fantaisie. Ce serait pas mal non plus si elle pouvait vérifier les paramètres entrants. Exactement comme un objet fantaisie. Il se pourrait bien que nous ayons à réaliser cette astuce régulièrement : serait-il possible d'automatiser la création de cette sous-classe ?

Un objet fantaisie partiel

Bien sûr la réponse est "oui" ou alors j'aurais arrêté d'écrire depuis quelques temps déjà ! Le test précédent a représenté beaucoup de travail, mais nous pouvons générer la sous-classe en utilisant une approche à celle des objets fantaisie.

Voici donc une version avec objet fantaisie partiel du test...

Mock::generatePartial(
        'Telnet',
        'TelnetTestVersion',
        array('_createSocket'));

class TelnetTest extends UnitTestCase {
    ...
    function testConnection() {
        $socket = &new MockSocket($this);
        ...
        $telnet = &new TelnetTestVersion($this);
        $telnet->setReturnReference('_createSocket', $socket);
        $telnet->Telnet();
        $telnet->connect('127.0.0.1', 21, 'Me', 'Secret');
        ...
    }
}
La fantaisie partielle est une sous-classe de l'original dont on aurait "remplacé" les méthodes sélectionnées avec des versions de test. L'appel à generatePartial() nécessite trois paramètres : la classe à sous classer, le nom de la nouvelle classe et une liste des méthodes à simuler.

Instancier les objets qui en résultent est plutôt délicat. L'unique paramètre du constructeur d'un objet fantaisie partiel est la référence du testeur unitaire. Comme avec les objets fantaisie classiques c'est nécessaire pour l'envoi des résultats de test en réponse à la vérification des attentes.

Une nouvelle fois le constructeur original n'est pas lancé. Indispensable dans le cas où le constructeur aurait besoin des méthodes fantaisie : elles n'ont pas encore été initiées ! Nous initions les valeurs retournées à cet instant et ensuite lançons le constructeur avec ses paramètres normaux. Cette construction en trois étapes de "new", suivie par la mise en place des méthodes et ensuite par la lancement du constructeur proprement dit est ce qui distingue le code d'un objet fantaisie partiel.

A part pour leur construction, toutes ces méthodes fantaisie ont les mêmes fonctionnalités que dans le cas des objets fantaisie et toutes les méthodes non fantaisie se comportent comme avant. Nous pouvons mettre en place des attentes très facilement...

class TelnetTest extends UnitTestCase {
    ...
    function testConnection() {
        $socket = &new MockSocket($this);
        ...
        $telnet = &new TelnetTestVersion($this);
        $telnet->setReturnReference('_createSocket', $socket);
        $telnet->expectOnce('_createSocket', array('127.0.0.1', 21));
        $telnet->Telnet();
        $telnet->connect('127.0.0.1', 21, 'Me', 'Secret');
        ...
        $telnet->tally();
    }
}

Tester moins qu'une classe

Les méthodes issues d'un objet fantaisie n'ont pas besoin d'être des méthodes fabrique, Il peut s'agir de n'importe quelle sorte de méthode. Ainsi les objets fantaisie partiels nous permettent de prendre le contrôle de n'importe quelle partie d'une classe, le constructeur excepté. Nous pourrions même aller jusqu'à créer des fantaisies sur toutes les méthode à part celle que nous voulons effectivement tester.

Cette situation est assez hypothétique, étant donné que je ne l'ai jamais essayée. Je suis ouvert à cette possibilité, mais je crains qu'en forçant la granularité d'un objet on n'obtienne pas forcément un code de meilleur qualité. Personnellement j'utilise les objets fantaisie partiels comme moyen de passer outre la création ou alors de temps en temps pour tester le modèle de conception TemplateMethod.

Pour choisir le mécanisme à utiliser, on en revient toujours aux standards de code de votre projet.

Pour aller plus loin...