Lors de notre article sur les closures, nous avions mis en avant le problème suivant: PHP ne permet pas de rajouter ou remplacer une méthode à la volée de manière native.
Nous avions parlé d’une extension (donc à installer) expérimentale (donc pas exploitable en production) qui peut affecter le comportement des classes. Mais essayons de nous en passer pour l’instant.
D’autre part, il est assez difficile de récupérer le code de base d’une classe sous forme de chaîne de caractères, pour retravailler ensuite sa déclaration et instancier un objet trafiqué. Il faut aller rechercher le fichier associé à la classe et tokenizer son contenu. Encore faut-il ensuite pouvoir exploiter ces données…
Bref, aucune possibilité d’altérer le comportement d’un objet (On parle aussi de Monkey patch) ne semble s’offrir à nous de façon simple.
Dès lors, comment mocker une méthode? Comment faire en sorte qu’un objet retourne une valeur arbitraire, sans que le code de sa classe ne prévoie, à la base, cette possibilité?
Comment faire pour implémenter ce genre de fonctionnalité proposée par PHPUnit:
1 2 3 | $bouchon->expects($this->any()) ->method('faireQuelquechose') ->will($this->returnCallback('str_rot13')); |
Voici l’occasion de mettre en pratique ce que nous avons vu dans nos articles précédents, avec un exemple simple de binding en PHP…
MockBuilder
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | class MockBuilder { /** * template de création de classe * * @var string */ private static $classTemplate = <<<'EOD' class {{ className}} extends {{ parentClassName }}{ private $closures = array(); {{ methods }} } EOD; /** * template de création de méthode * * @var string */ private static $methodTemplate = <<<'EOD' function {{ methodName }}(){ return call_user_func_array( $this->closures['{{ methodName }}'], func_get_args() ); } EOD; /** * retourne un Mock depuis $object. * le Mock est une classe $mockName étendant $object. * le mock écrase les méthodes $methods sous forme de Closure * * @param object $object * @param array $methods * @param string $mockName * @return string */ public function getMock($object, array $methods, $mockName = '') { if (empty($mockName)) { $mockName = 'Mock_'.get_class($object).'_'.uniqid(); } eval($this->getMockCode($mockName, get_class($object), $methods)); return $this->addMethods(new $mockName(), $methods); } /** * retourne une string contenant la déclaration d'une classe Mock * * @param string $className * @param string $parentClassName * @param array $methods * @return string */ private function getMockCode($className, $parentClassName, array $methods) { return Template::render( self::$classTemplate, array( 'className' => $className, 'parentClassName' => $parentClassName, 'methods' => $this->getMethodsCode($methods), ) ); } /** * retourne le code des méthodes de la classe de mock. * * @param array $methods * @return string */ private function getMethodsCode(array $methods) { $methodCode = ''; foreach ($methods as $methodName => $closure) { $methodCode .= Template::render( self::$methodTemplate, array( 'methodName' => $methodName ) ); } return $methodCode; } /** * ajoute des méthodes à $mock sous forme de Closure * * @param $mock * @param array $methods * @return string */ private function addMethods($mock, array $methods) { $reflectObject = new ReflectionClass($mock); $closures = $reflectObject->getProperty('closures'); $closures->setAccessible(true); $closures->setValue($mock, $this->bindMethods($mock, $methods)); return $mock; } /** * bind les méthodes $methods en leur passant le contexte de $mock * * @param $mock * @param array $methods * @return array */ private function bindMethods($mock, array $methods) { $result = array(); foreach ($methods as $methodName => $method) { $result[$methodName] = Closure::bind( $method, $mock, get_parent_class($mock) ); } return $result; } } |
La classe MockBuilder construit une classe de mock héritant de l’objet que l’on veut mocker (MockBuilder::getMock()). Ainsi, notre mock héritera du comportement par défaut de l’objet à mocker.
Pour ce faire, elle définit une chaîne de caractères contenant la déclaration d’une classe. Cette déclaration est obtenue depuis des templates que l’on vient compléter à l’aide de la classe Template (MockBuilder::getMockTemplate()).
La déclaration va toutefois réécrire les méthodes à mocker, pour écraser le comportement de l’objet à mocker. Le corps de chacune de ces méthodes est assez simple et renvoie vers une closure associée.
Une fois la déclaration terminée, elle est évaluée pour qu’il soit ensuite possible d’instancier un objet.
Enfin, on passe à cet objet, via Reflection, la liste des méthodes à mocker (MockBuilder::addMethods()), en utilisant le binding proposé par Closure (MockBuilder::bindMethods()). Attention, la subtilité c’est de binder le contexte de l’objet à mocker, pour bénéficier de la visibilité privée de cet objet.
Le mock contient donc des méthodes qui écrasent les méthodes parentes et qui appellent les closures bindées avec le contexte du parent également.
Template
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | class Template { const VARIABLE_START_TOKEN = '{{'; const VARIABLE_END_TOKEN = '}}'; /** * parse une string en remplaçant les valeurs de $vars dont les clés se trouvent entre self::VARIABLE_START_TOKEN et self::VARIABLE_END_TOKEN. * * @param string $text * @param array $vars [variable => value] * @return mixed */ public static function render($text, array $vars) { return preg_replace( array_map( function ($keyword) { return '/' .Template::VARIABLE_START_TOKEN .'\s*' .$keyword .'\s*' .Template::VARIABLE_END_TOKEN .'/'; }, array_keys($vars) ), $vars, $text ); } } |
La classe Template permet juste de parser un template en lui passant des variables, un peu à la manière de Twig, mais, évidemment, en infiniment moins puissant.
Instanciation du mock
1 2 3 4 5 6 7 8 9 10 11 12 13 | class Bar{ public $a = 'a'; private $b = 'b'; function __construct(){ $this->b = 'c'; } function foo(){ return $this->a; } } |
On instancie notre classe de test dont on constate le comportement normal.
1 2 | $bar = new Bar(); var_dump($bar->foo()); //a |
On crée un mock depuis l’objet de notre classe de test, tout en lui demandant de mocker sa méthode foo à l’aide d’une closure spécifique utilisant le contexte de l’objet à mocker.
1 2 3 4 5 6 7 8 9 | $mockBuilder = new MockBuilder(); $mock = $mockBuilder->getMock( $bar, array( 'foo' => function(){ return $this->b; } ) ); |
Notre mock altère bien le fonctionnement de notre objet de base.
1 | var_dump($mock->foo()); //c |
Tadaaam!
Seule restriction de cette solution: tout comme avec PHPUnit, il n’est pas possible de mocker une méthode privée…
Ping : Closure, callback et fonctions dynamiques en PHP