Mocker une méthode avec une closure en PHP

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… :-D

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! :-D

Seule restriction de cette solution: tout comme avec PHPUnit, il n’est pas possible de mocker une méthode privée…

Une réflexion au sujet de « Mocker une méthode avec une closure en PHP »

  1. Ping : Closure, callback et fonctions dynamiques en PHP

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>