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) :

Séquence d'appel lors de l'exécution d'un test unitaire (test avec 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 :

  1. Paramétrage du mock : on dit au bouchon quel est le résultat qu'il doit renvoyer quand on va l'appeller
  2. 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
  3. 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)
  4. Appel de l'adapter par le service: le mock est alors appelé par le service avec d'éventuels arguments préparés
  5. 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)
  6. Post-logique: le service traite le résultat de l'adapter (filtrage, trie, transformation...)
  7. Renvoie du résultat attendue par le test: le service renvoie le résultat final au test unitaire
  8. 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)
  9. Le test récupère les paramètres d'appel du mock par le service
  10. 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
  11. 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 ?