Closures, Anonymous Classes and an alternative approach to Test Mocking (Part 4)

In a prior article in this series, I described the use of a SpyMaster Class to create proxy spies as anonymous classes, that allow external visibility (and potentially update) of protected and private properties within an object. The same basic principle of a spy can also be used to create a proxy that gives us access to execute the private and protected methods of an object.

Why might we want to do this? One possible reason is that it gives us the ability to test the internal methods of an object in isolation from other code within the public methods that we would normally test.

As an example, we’ll experiment with the following class:


class Distance {
    const METRES          = 'm';      //    metre (SI base unit)
    const KILOMETRES      = 'km';     //    1000 metres (SI unit)
    const MILES           = 'mi';     //    mile (International)
    const NAUTICAL_MILES  = 'nmi';    //    nautical mile (International)
    const YARDS           = 'yds';    //    yard (International)

    protected static $conversions = [
        self::METRES          => 1.0,
        self::KILOMETRES      => 1000.0,
        self::MILES           => 1609.344,
        self::NAUTICAL_MILES  => 1852.0,
        self::YARDS           => 0.9144,
    ];

    protected $distance = 0.0;

    function __construct($distance = null, $uom = self::METRES) {
        if ($distance !== null) {
            $this->setValue($distance, $uom);
        }
    }

    protected function validateUoM($uom) {
        if (!isset(self::$conversions[$uom])) {
            throw new Exception('Invalid Unit of measure for conversion');
        }
    }

    public function setValue($distance = 0.0, $uom = self::METRES) {
        $this->validateUoM($uom);
        $this->distance = $this->convertToMeters($distance, $uom);
    }

    public function getValue($uom = self::METRES) {
        $this->validateUoM($uom);
        return $this->convertFromMeters($this->distance, $uom);
    }

    protected function convertToMeters($distance = 0.0, $uom = NULL) {
        $factor = self::$conversions[$uom];

        return $distance * $factor;
    }

    protected function convertFromMeters($distance = 0.0, $uom = NULL) {
        $factor = self::$conversions[$uom];

        return $distance / $factor;
    }
}

This class contains a number of protected methods – the conversions and the validator – that couldn’t ordinarily be tested in isolation from the constructor, getter or setter methods. But if we can call (for example) the convertToMeters() method directly from our tests, we can verify that it works as expected, without having to set a value through the constructor or setter, and without the UoM validation check; and the UoM validation method can also be tested independently. We will (of course) also want to test the public setter and getter methods separately, but running those can be @depends on the successful result of the tests for the protected methods.


Unlike the original SpyMaster that I wrote about last July, we’re going to take a slightly different approach here, providing our spies with a “mission”.


class SpyObjectMission {
    public $methods = [];
    public $invoker;

    public function __construct($targetObject) {
        $this->methods = $this->getTargets($targetObject);
        $this->invoker = $this->getInvoker($targetObject);
    }

    protected function getTargets($targetObject) {
        $reflector = new \ReflectionObject($targetObject);
        $staticMethods = array_column($reflector->getMethods(\ReflectionMethod::IS_STATIC), 'name');
        // get a list of all the object methods in the target object (excluding the constructor)
        return array_diff(
            array_column($reflector->getMethods(), 'name'),
            array_merge(['__construct'], $staticMethods)
        );
    }

    protected function getInvoker($targetObject) {
        $invoker = function($methodName, ...$args) {
            return $this->$methodName(...$args);
        };
        return $invoker->bindTo($targetObject, get_class($targetObject));
    }
}

When created with an object instance that we want to proxy, this “mission” will provide our spy with a set of targets (the methods that it should proxy), and with an “invoker” to execute those methods when called upon to do so. We’re only targeting object methods, not static methods (for the moment), and explicitly ignoring the constructor (we could filter out all magic methods), or we could easily modify it to provide an explicit target list of named methods; but in this simple case, the getTargets() method just uses Reflection to get a list of all the methods available in that object by name, then filters out those that we’re not interested in working with. The getInvoker() method creates a callback that we can use to execute any named method with a specified set of arguments, and then binds that callback to the target object. Both the target list (methods) and the invoker are stored as object properties in our mission object.

Next, we have our SpyMaster class.


class SpyMaster {
    private $targetObject;

    protected $objectMission;

    public function __construct($targetObject) {
        $this->targetObject = $targetObject;
        $this->objectMission = new SpyObjectMission($targetObject);
    }

    protected function getHandler() {
        return new class ($this->objectMission) {
            private $objectMission;

            public function __construct(SpyObjectMission $objectMission) {
                $this->objectMission = $objectMission;
            }

            public function __call($methodName, $args) {
                if (in_array($methodName, $this->objectMission->methods)) {
                    $invoker = $this->objectMission->invoker;
                    return $invoker($methodName, ...$args);
                }
                throw new Exception("Object Method {$methodName} does not exist");
            }
        };
    }

    public function infiltrate() {
        return $this->getHandler();
    }
}

When we instantiate the SpyMaster object, we do so specifying the target object that we want to spy on. The constructor then creates the “mission” briefing for our spies, while the infiltrate() method creates and returns an anonymous class to act as our spy.

When we create our anonymous spy, we pass the the mission object to its constructor to store for future use, and we create the spy object with a magic __call() method that can validate that the method name is in our list of callable methods, and then execute it using the mission invoker that was bound to the target object instance when we created the mission, so it will call the method of the same name within the target object. Any arguments passed to our spy will be passed on through to the target method that we are going to execute.


$distance = new Distance(10, Distance::MILES);

$spy = (new SpyMaster($distance))->infiltrate();

Now, we are able to call the private or protected methods of the Distance object proxying the method call (and any arguments) through the spy that SpyMaster has created for us.


echo $spy->convertToMeters(10, Distance::MILES), PHP_EOL;
echo $spy->convertFromMeters(1000, Distance::MILES), PHP_EOL;

Executing those two calls to the protected convertToMeters() and convertFromMeters() methods of the Distance class instance through the spy proxy that we’ve created gives us the expected result of

16093.44
0.62137119223733

showing that it is possible to access those internal methods through the proxy.


The spy that we’ve created in this example only works with object methods, not with static methods. We need to take a slightly different approach to execute static methods that aren’t publicly visible, but that’s something I’ll address in a future article.

Advertisements
This entry was posted in PHP and tagged , , , , , . Bookmark the permalink.

2 Responses to Closures, Anonymous Classes and an alternative approach to Test Mocking (Part 4)

  1. Pingback: 今月のPHP – 2018年2月 | JetBrains ブログ

  2. Pingback: PHP Annotated Monthly – February 2018 | PhpStorm Blog

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s