Одним прекрасным рабочим днём я писал 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)


  1. Joka
    05.05.2019 12:05
    +1

    Если вам нужно инициализировать для тестов что-то приватное в классе через вот такие хитрые подвыподверты, то в первую очередь стоит подумать, а хороший ли это код? Описанный способ интересный, однако думаю стоит все таки отрефакторить класс в который вы так нагло врываетесь


    1. AlexandrEsaulov
      05.05.2019 18:02

      Здесь основной посыл статьи не в том, как тесты в итоге реализовать пришлось, а в том, что с помощью Closure::bind() действительно можно сломать то, что якобы сломать невозможно.


      Если мы заменим код на такой:


      $createNewInstance = function () {
          $instance =  new self();
          self::$instance = $instance;
          return $instance;
      };

      то мы вовсе заменим оригинальный объект новым.


      Что-то здесь действительно не так, мы с помощью этого функционала можем вообще влезть в логику любого класса, работать с приватными свойствами и методами. Понятие инкапсуляция теперь очень размытое.


      Следующим что будет, добавление новых методов в классы? :)


      1. Joka
        05.05.2019 18:08

        Да, я понимаю. Не только такие методы есть для того чтобы влезать внутрь «мозгов». Есть спец расширения для ре-дефайна переменных и подмены статических функций и тп. Но философский вопрос: а стоит ли о таком вообще знать и на кой черт это вообще создали разработчики PHP если вроде как мы тут все в мире боремся за качество кода?


        1. VolCh
          05.05.2019 21:55

          Такие вещи часто нужны чтобы не выдавать наружу все кишки объекта для инфраструктурных задач типа (де)сериализации, (де)гидратации, ленивой загрузки и того же тестирования. Они позволяют не иметь в объекте сеттеров, не нарушать SRP и т. п.


          1. EvgeniiR
            06.05.2019 10:58

            Не совсем понятно чем рефлексия не подошла. Если только, как указали ниже, ради производительности


            1. VolCh
              06.05.2019 11:18
              +1

              Скорее всего именно в производительности дело, рефлексия — дорогая. А в универсальных решениях часто рефлексия используется для анализа, получения списка свойств, а кложуры для собственно манипуляций. В идеале вообще рефлексия в «компайл-тайме» (кодогенерация) осуществляется, а в «ран-тайме» уже заточенные под классы акцессоры.


            1. greeflas Автор
              06.05.2019 11:34

              Именно, не использовал рефлексию для улучшения производительности тестов.


        1. guyfawkes
          07.05.2019 00:00
          +1

          Более того, это вовсю используется в PSR-реализации кеша той же симфони: github.com/symfony/cache/blob/master/Adapter/AbstractAdapter.php#L41


      1. Fortop
        07.05.2019 13:49
        +1

        А рефлексия вам инкапсуляцию не размывала?


    1. greeflas Автор
      05.05.2019 18:22

      Мне нужно было задать начальное состояние объекта, для того чтобы протестировать изменение этого состояния. Грубо говоря — протестировать сеттер для свойства класса, так как в нём есть логика валидации.


      1. Joka
        05.05.2019 18:24

        начальное состояние должно задаваться в самом классе вроде private $_var = null;
        перед запуском сеттера вы можете убедиться что начальное состояние задано в дефолт через геттер.
        тут вообще не нужно лезть в потроха класса, если там приватные переменные вас как вызывающего вообще не должно волновать что там внутри. сделайте black-box тестирование на этот класс и все


        1. greeflas Автор
          05.05.2019 18:38
          +1

          Согласен. Спасибо!



  1. EvgeniiR
    05.05.2019 13:19
    +1

    Ваши тесты проверяют не поведение, а внутреннее состояние экземпляра класса, которое, вобщем то, клиентский код волновать не должно.
    Зачем нужны модульные тесты, которые взаимодействуют с объектом совершенно иным образом, чем это делает клиентский код?


    1. vanxant
      05.05.2019 23:12

      Слово «модульные» вы произнесли первым, в статье этого нет.


      1. EvgeniiR
        06.05.2019 10:56
        +1

        "Одним прекрасным рабочим днём я писал unit-тесты ..."


    1. dimm_ddr
      06.05.2019 12:52
      +1

      Мне кажется это справедливо не только для модульных тестов. Если внешний код не может что-то сделать (неважно клиентский он или просто другой класс ваш же), то и проверять эту функциональность большого смысла нет.
      Но при этом лезть в класс как это делал автор поста вполне может понадобиться — когда это легаси код рефакторить который сейчас слишком дорого, а его тестируемость хромает, то это может оказаться самым быстрым, простым и в итоге правильным способом протестировать что-либо. Например когда как в посте нужно проверить поведение в определенном состоянии.


  1. mafia8
    05.05.2019 15:12

    Можно ли редактировать класс, в котором надо инициализировать приватные свойства? Если да, то можно добавить в класс функцию, которая инициализирует свойства.


    1. VolCh
      05.05.2019 21:56

      Нарушение SRP, нет?


  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();

    Песочница


    1. abyrvalg
      06.05.2019 06:09

      На сколько я помню из своих тестов, вариант с биндингом примерно в три раза быстрее рефлексии.


  1. drch
    06.05.2019 12:05

    Удаленный комментарий