Test unitaire: comment bouchonner ?
L'exécution d'un test unitaire nécessite en règle générale la mise en place de bouchon (mock en anglais) qui permettent de simuler les appels systèmes ou d'api sous jacent.
En effet, il peut être difficile de tester une partie de votre code qui a des impacts sur le système externe (enregistrement sur disque, appels réseaux / webservices, stockage mémoire vive, appels base de données...), le principe du bouchon est alors bien pratique pour tester la logique sans déclencher toutes les opérations.
Le schéma ci-dessous décrit un exemple de séquence d'exécution permettant de mettre en oeuvre un test unitaire (test avec bouchon) sur une méthode d'une classe (appellée "Service") qui elle même utilise une méthode d'une autre classe pour récupérer la liste non triée brute des commandes. Il est ici nécessaire de bouchonner car la récupération de la liste des commandes brutes est ici réalisées par l'appel d'un webservice SOAP, qu'il est hors de question de déclencher lors de l'exécution des tests unitaires (nous utiliserons alors le bouchon) :
Voici un exemple de code que l'on pourrait associer à ce schéma :
La classe de test
<?php class MyApp_Order_ServiceTest extends PHPUnit_Framework_TestCase { public function setUp() { $this->fixture = new MyApp_Order_Service(); $this->fixture->setAdapter( new MyApp_Order_Adapter_Mock()); /** * ou bien: * * $this->fixture = * Myapp_Order_Service::factory('mock'); */ } public function test_listOrdersForDate_forNoOrders_returnEmptyArray() { // 1. Paramétrer le mock $this->fixture->getAdapter() ->addMethodExpectedResult( 'listForDate', array()); /** * 2,3,4,5,6 et 7. Appeller la méthode du * service que l'on teste */ $result = $this->fixture ->listOrdersByDate('2009-09-26'); /** * 8 et 9. Demander au mock si il a été * appellé et avec quels paramètres */ $params = $this->fixture->getAdapter() ->getLastMethodCallParams('listForDate'); /** * 10. Vérifier (tester) le résultat du * service */ $this->assertEquals(array(),$result); /** * 11. Vérifier (tester) les paramètres * envoyés par le service à l'adapter */ $this->assertEquals(array('2009-09-26'),$params); } // ... }
Le "Service"
<?php /** * Fonctionnalités liées aux "Commandes" * * La classe parent (MyApp_Abstract_Service) fournis * toutes les méthodes génériques pour les services: * * setAdapter(), getAdapter()... */ class MyApp_Order_Service extends MyApp_Abstract_Service { public function listOrdersForDate($date) { if (false === preg_match( "|^20[0-9]{2}\-[0-9]{2}\-[0-3][0-9]$|", $date)) { throw new RuntimeException( "Bad date format ". "(expected YYYY-MM-DD, got: '$date')"); } try { $rawList = $this ->getAdapter() ->listForDate($date); } catch (Exception $e) { MyApp_Logger::logException($e); throw new RuntimeException( "An error occured when retrieving ". "orders list for date ". "'$date': ".$e->getMessage()); } $orders = array(); foreach($rawList as $order) { $orders[$order['id']] = array( 'nbItems'=>count($order['items']), 'amount'=>$order['price'], ); } asort($orders); return $orders; } // ... }
L'Adapter Mock (bouchon)
<?php /** * Bouchon pour les fonctionnalités "Commandes" * * La classe parent (MyApp_Abstract_Mock) fournis * toutes les méthodes génériques pour les bouchons: * * getLastMethodCallParams(), * markMethodExecutionAndReturnExpectedResult(), ... */ class MyApp_Order_Adapter_Mock extends Myapp_Abstract_Mock implements MyApp_Order_Adapter_Interface { public function listForDate($date) { $args = func_get_args(); return $this ->markMethodExecutionAndReturnExpectedResult( __FUNCTION__, $args); } // ... }
L'Adapter standard (celui qui appelle le SOAP, mais qui n'est pas utilisé dans les tests unitaires)
<?php /** * Implémentation native pour les fonctionnalités "Commandes" * * L'interface MyApp_Order_Adapter_Interface définit la liste * des méthodes à implémenter dans cet adapter */ class Myapp_Order_Adapter_Default implements MyApp_Order_Adapter_Interface { /** * @var SoapClient */ private $soapClient; public function __construct() { $this->soapClient = new SoapClient(...); } public function listForDate($date) { return $this->soapClient->listForDate($date); } // ... }
La séquence
La séquence d'appel est donc la suivante :
- Paramétrage du mock : on dit au bouchon quel est le résultat qu'il doit renvoyer quand on va l'appeller
- Appel de la méthode testée par le test unitaire: on exécute la méthode avec les paramètres en dur depuis le test unitaire
- Pré-logique du service : la méthode commence par traiter les arguments et se préparer avant l'appel de l'adapter (mock dans notre cas)
- Appel de l'adapter par le service: le mock est alors appelé par le service avec d'éventuels arguments préparés
- Renvoie du résultat par l'adapter: le mock renvoie "bêtement" le résultat qu'on lui a demandé de renvoyer lors du paramétrage (étape 1)
- Post-logique: le service traite le résultat de l'adapter (filtrage, trie, transformation...)
- Renvoie du résultat attendue par le test: le service renvoie le résultat final au test unitaire
- Le test vérifie que le mock a bien été appelé par le service (pour être sur que tout s'est bien passé, n'oublions pas le service est une boite noire, lorsque l'on fait un test on n'est pas censé savoir comment le service est implémenté, mais uniquement que son comportement)
- Le test récupère les paramètres d'appel du mock par le service
- Vérification du résultat attendu: 1ère assertion sur le résultat final, on vérifie que la liste retournée par le service est celle attendue dans le cadre de ce test
- Vérification des paramètres fournis par le service à l'adapter (mock): 2ème assertion sur l'appel du mock par le service, on vérifie que le service a bien eu le comportement attendu sur l'adapter (mock)
Les étapes 8, 9 et 11 sont souvent oubliés, elles sont cependant de mon point de vue nécessaire pour garantir que le test unitaire met en oeuvre un véritable bouchon et que seul la logique que l'on souhaite testée est exécutée (sinon, il ne s'agit pas d'un test unitaire, mais plutôt d'un test d'intégration...).
Cette technique est bien entendu a utiliser pour bouchonner les appels à la base de données...
Et vous, comment faites vous pour bouchonner votre code, notamment dans le cadre des tests unitaires ?

Commentaires
Complément d'information posté sur le blog de Clochix (http://www.clochix.net/post/2009/09...) concernant l'utilisation des fonctionnalité Mock de PHPUnit:
Pour ma part, j'évite de l'utiliser (i.e. la fonctionnalité Mock de PHPUnit) car je souhaite rester indépendant d'un quelconque framework lors de mon développement.
Les fonctionnalités Mock de PHPUnit sont très intéressantes, elles ont cependant de mon point de vue un défaut majeure dans l'utilisation que je fais des mocks. En effet, lorsque je développe j'utilise la version "mock" de mes classes (adapter) dans mon code tant que je n'ai pas développé la version native (standard). Cela me permet de prototyper / présenter rapidement les fonctionnalités "statiques" de mes développements et de "débrancher" lorsque je suis prêt le mock pour mettre la vraie implémentation (imaginez que je développe un webservice et que je doive rapidement fournir une version de mon webservice pour des consommateurs qui seraient "impatients"). Le problème avec le framework PHPUnit est qu'il n'est pas adapté a priori pour une utilisation en dehors des tests unitaires.
autre part les mocks peuvent être utiles dans d'autres cas encore que les tests unitaires et celui que je décris, par exemple certains tests d'intégration qui porte sur d'autres zones du code... même remarque dans ce cas.
Et vous quels sont vos pratiques pour mettre en place des bouchons dans les cas autres que les tests unitaires ?