Одним прекрасным рабочим днём я писал unit-тесты для бизнес-логики на проекте, в котором работаю. Передо мною стояла задача инициализировать некоторые приватные свойства класса определёнными значениями.
Обычными сеттерами нельзя было пользоваться, так как там была прописана некая логика. Унаследовать или замокать класс тоже не получалось, потому что он объявлён финальным. И даже рефлексия не подошла. Поэтому я начал искать варианты решения этой проблемы.
Нашел интересную статью, в которой описано, как с помощью бибилотеки dg/bypass-finals можно замокать финальный класс. Этот вариант мне понравился и я попробовал его внедрить. К сожалению, у меня ничего не получилось, так как на проекте используется старая версия PHPUnit.
Поразмыслив, я вспомнил о классе Closure
, а конкретно о его статическом методе bind()
, который умеет внедрять анонимные функции в контекст нужного объекта какого-либо класса. Больше информации об этом можно найти в официальной документации. Поэтому я создал трейт, который использовал в своих тестах (может кому-то тоже будет полезен)
trait PrivatePropertySetterTrait
{
protected function assignValue($object, string $attribute, $value)
{
$setter = function ($value) use ($attribute) {
$this->$attribute = $value;
};
$setterClosure = \Closure::bind($setter, $object, \get_class($object));
$setterClosure($value);
}
}
Данный трейт принимает объект класса, название свойства, куда нужно установить значение и, собственно, само значение. Далее объявляется простая анонимная функция, которая с помощью указателя $this
присваивает полученное значение в свойство класса. Дальше в бой идёт класс Closure
с его статическим методом bind()
. Метод принимает объект класса, анонимную функцию, описанную выше, и полное имя класса. Таким образом, анонимная функция внедряется в контекст объекта и метод bind()
возвращает нам объект класса Closure
, который мы можем вызвать как обычную функцию, потому как он определяет магический метод __invoke()
. И вуаля!
В итоге мне удалось решить мою проблему, и тогда я вспомнил о шаблоне проектирования Singleton. Получится ли таким же способом внедрить анонимную функцию, которая будет создавать новые объекты класса? Конечно же я пошёл это проверять!
Написав небольшой кусок кода
Песочница с кодом
<?php
final class Singleton
{
private static $instance;
public static function getInstance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct()
{
}
private function __clone()
{
}
private function __wakeup()
{
}
}
$s1 = Singleton::getInstance();
\var_dump(\spl_object_id($s1));
$createNewInstance = function () {
return new self();
};
$newInstanceClosure = Closure::bind($createNewInstance, $s1, Singleton::class);
$s2 = $newInstanceClosure();
\var_dump(\spl_object_id($s2));
который работает по такому же принципу, только вместо присвоения значения свойству класса — создаётся новый объект с помощью оператора new
. Функция \spl_object_id()
возвращает уникальный идентификатор объекта. Больше информации об этой функции можно найти в документации. С помощью spl_object_id()
и var_dump()
вывожу уникальные идентификаторы объектов и вижу то что они отличаются! Мне всё же удалось подтвердить эту теорию и создать новый екземпляр Singleton класса!
В этой статье я хотел поделиться с сообществом PHP моей весьма любопытной находкой.
Спасибо за внимание!
Комментарии (22)
UksusoFF
05.05.2019 13:12
EvgeniiR
05.05.2019 13:19+1Ваши тесты проверяют не поведение, а внутреннее состояние экземпляра класса, которое, вобщем то, клиентский код волновать не должно.
Зачем нужны модульные тесты, которые взаимодействуют с объектом совершенно иным образом, чем это делает клиентский код?dimm_ddr
06.05.2019 12:52+1Мне кажется это справедливо не только для модульных тестов. Если внешний код не может что-то сделать (неважно клиентский он или просто другой класс ваш же), то и проверять эту функциональность большого смысла нет.
Но при этом лезть в класс как это делал автор поста вполне может понадобиться — когда это легаси код рефакторить который сейчас слишком дорого, а его тестируемость хромает, то это может оказаться самым быстрым, простым и в итоге правильным способом протестировать что-либо. Например когда как в посте нужно проверить поведение в определенном состоянии.
Yeah
05.05.2019 22:11Обычными сеттерами нельзя было пользоваться, так как там была прописана некая логика. Унаследовать или замокать класс тоже не получалось, потому что он объявлён финальным. И даже рефлексия не подошла.
ЧЯДНТ:
final class Foo { private $prop; public function __construct() { $this->prop = 1; } public function getProp() { return $this->prop; } } $foo = new Foo(); $fooR = new ReflectionObject($foo); $prop = $fooR->getProperty('prop'); $prop->setAccessible(true); $prop->setValue($foo, 2); echo $foo->getProp();
abyrvalg
06.05.2019 06:09На сколько я помню из своих тестов, вариант с биндингом примерно в три раза быстрее рефлексии.
Joka
Если вам нужно инициализировать для тестов что-то приватное в классе через вот такие хитрые подвыподверты, то в первую очередь стоит подумать, а хороший ли это код? Описанный способ интересный, однако думаю стоит все таки отрефакторить класс в который вы так нагло врываетесь
AlexandrEsaulov
Здесь основной посыл статьи не в том, как тесты в итоге реализовать пришлось, а в том, что с помощью Closure::bind() действительно можно сломать то, что якобы сломать невозможно.
Если мы заменим код на такой:
то мы вовсе заменим оригинальный объект новым.
Что-то здесь действительно не так, мы с помощью этого функционала можем вообще влезть в логику любого класса, работать с приватными свойствами и методами. Понятие инкапсуляция теперь очень размытое.
Следующим что будет, добавление новых методов в классы? :)
Joka
Да, я понимаю. Не только такие методы есть для того чтобы влезать внутрь «мозгов». Есть спец расширения для ре-дефайна переменных и подмены статических функций и тп. Но философский вопрос: а стоит ли о таком вообще знать и на кой черт это вообще создали разработчики PHP если вроде как мы тут все в мире боремся за качество кода?
VolCh
Такие вещи часто нужны чтобы не выдавать наружу все кишки объекта для инфраструктурных задач типа (де)сериализации, (де)гидратации, ленивой загрузки и того же тестирования. Они позволяют не иметь в объекте сеттеров, не нарушать SRP и т. п.
EvgeniiR
Не совсем понятно чем рефлексия не подошла. Если только, как указали ниже, ради производительности
VolCh
Скорее всего именно в производительности дело, рефлексия — дорогая. А в универсальных решениях часто рефлексия используется для анализа, получения списка свойств, а кложуры для собственно манипуляций. В идеале вообще рефлексия в «компайл-тайме» (кодогенерация) осуществляется, а в «ран-тайме» уже заточенные под классы акцессоры.
greeflas Автор
Именно, не использовал рефлексию для улучшения производительности тестов.
guyfawkes
Более того, это вовсю используется в PSR-реализации кеша той же симфони: github.com/symfony/cache/blob/master/Adapter/AbstractAdapter.php#L41
Fortop
А рефлексия вам инкапсуляцию не размывала?
greeflas Автор
Мне нужно было задать начальное состояние объекта, для того чтобы протестировать изменение этого состояния. Грубо говоря — протестировать сеттер для свойства класса, так как в нём есть логика валидации.
Joka
начальное состояние должно задаваться в самом классе вроде private $_var = null;
перед запуском сеттера вы можете убедиться что начальное состояние задано в дефолт через геттер.
тут вообще не нужно лезть в потроха класса, если там приватные переменные вас как вызывающего вообще не должно волновать что там внутри. сделайте black-box тестирование на этот класс и все
greeflas Автор
Согласен. Спасибо!