Mock-объект в рабочем коде, или как тестовый двойник помог решить проблему излишне связанного кода

от автора

На работе была поставлена задача: в главное веб-приложение нашей фирмы добавить метод формирования бланка в формате PDF «как вот в том микросервисе».

Форма бланка регулярно изменяется, и копировать её в веб-приложение означало нарушить принцип DRY («Не повторяйся») и обречь себя на постоянную двойную работу. Поэтому я решил оставить генерацию бланка в «том микросервисе».

«Тот микросервис» написан на PHP с использованием фреймворка Laravel, содержит большое число доменных объектов, экземпляры которых хранятся в БД MySQL, и имеет развитую систему API для обращения к своему функционалу.

И можно было добавить в него ещё одну точку доступа API, которая бы получала данные и на их основе формировала и возвращала бланк.

Проблема возникла из-за «неприлично» высокой связанности объектов в «том микросервисе». Так, в шаблоне, на основе которого строился бланк, использовались не просто примитивные типы данных, а объект-форма. И шаблон обращался к методам-геттерам этого объекта. А объект, в свою очередь, использовал другой доменный объект в своём конструкторе для заполнения полей.

Примерно вот так:

class DataForBladeTemplate extends SomeLaravelBasicClass {     protected UnnecessaryDomainClass $unnecessaryDomainObject;      protected string $fieldOne;     protected string $fieldTwo;     protected string $fieldThree;      public function __construct(UnnecessaryDomainClass $unnecessaryDomainObject)     {         $this->unnecessaryDomainObject = $unnecessaryDomainObject;          $this->fieldOne = $unnecessaryDomainObject->someMethodWhichReturnFieldOneValue();         $this->fieldTwo = $unnecessaryDomainObject->someMethodWhichReturnFieldTwoValue();         $this->fieldThree = $unnecessaryDomainObject->someMethodWhichReturnFieldThreeValue();     }      /**      * @return string      */public  function getFieldOne(): string{         return $this->fieldOne;     }      /**      * @return string      */public  function getFieldTwo(): string{         return $this->fieldTwo;     }      /**      * @return string      */public  function getFieldThree(): string{         return $this->fieldThree;     } }  // И далее где-нибудь в шаблоне   {{ $dataForBladeTemplate->getFieldOne() }} 

Из-за этой связанности нельзя было просто создать пустой объект DataForBladeTemplate и заполнить его нужными данными.

Конечно, имелась возможность поменять логику получения данных в шаблоне. Вместо объекта передавать в макет отдельные переменные с примитивными типами данных или массив таких переменных. И шаблон обращался бы не к методам-геттерам переданного объекта, а к этим переменным или элементам массива. Но такое решение потребовало бы менять уже работающий код во многих местах и повышало риск совершения ошибки. Хотелось уменьшить как риск, так и трудозатраты.

К счастью, UnnecessaryDomainClass не внедрял никаких зависимостей в своём конструкторе и можно было просто создать его пустой экземпляр.

Поэтому нужно было «всего лишь» создать экземпляр DataForBladeTemplate, в конструктор которого передаётся пустой объект UnnecessaryDomainClass и это не вызывает «возражений» в его конструкторе, и самим заполнить его защищённые поля данными, прилетевшими в метод.

Сказано — сделано.

Привычка при тестировании создавать компактные тестовые двойники вручную не подвела. Не успела в голове сформулироваться задача, а руки сами стали набирать нужный код. Создаём анонимный класс, наследуемого от нужного нам. В нём переопределяем конструктор, чтобы избежать зависимости от доменного объекта, и переобъявляем все защищённые поля открытыми.

// Создаем анонимный класс, чтобы переопределить жесткие связи // с кодом микросервиса. $dataForBladeTemplate = new class(new UnnecessaryDomainClass()) extends DataForBladeTemplate {     protected UnnecessaryDomainClass $unnecessaryDomainObject;      public string $fieldOne;     public string $fieldTwo;     public string $fieldThree;     public function __construct(UnnecessaryDomainClass $unnecessaryDomainObject)     {         $this->unnecessaryDomainObject = $unnecessaryDomainObject;         // Больше ничего не делаем. Конструктор родителя не вызываем.     } };  ... // Заполняем поля этого объекта значениями ...  foreach ($request->fields as $field => $fieldValue) {     if (property_exists($dataForBladeTemplate, $field)) {         $dataForBladeTemplate->$field = $fieldValue;     } }   ... // и передаем в шаблон. return view('view_name', ['data' => $dataForBladeTemplate]);

И, о чудо, всё заработало. Казалось, задачу можно закрывать.

Но не оставляло ощущение какой-то недоделанности. Например, что если в подменяемом классе изменится состав полей? Или часть, или все поля будут объявлены закрытыми (приватными)? Или если объект в шаблоне в свою очередь использовал бы другой доменный объект в своём конструкторе для заполнения полей. И внедряемый объект также зависел от другого доменного объекта, который в свою очередь… Ну вы поняли. В контейнере зависимостей могла использоваться длинная цепочка создания объектов.

Например, если бы сложилась вот такая ситуация:

class DataForBladeTemplate extends SomeLaravelBasicClass {     protected UnnecessaryDomainClass $unnecessaryDomainObject;      private string $fieldOne;     private string $fieldTwo;     private string $fieldThree;      public function __construct(UnnecessaryDomainClass $unnecessaryDomainObject)     {         $this->unnecessaryDomainObject = $unnecessaryDomainObject;          $this->fieldOne = $unnecessaryDomainObject->someMethodWhichReturnFieldOneValue();         $this->fieldTwo = $unnecessaryDomainObject->someMethodWhichReturnFieldTwoValue();         $this->fieldThree = $unnecessaryDomainObject->someMethodWhichReturnFieldThreeValue();     }      /**      * @return string      */public  function getFieldOne(): string{         return $this->fieldOne;     }      /**      * @return string      */public  function getFieldTwo(): string{         return $this->fieldTwo;     }      /**      * @return string      */public  function getFieldThree(): string{         return $this->fieldThree;     } }  class UnnecessaryDomainClass extends AnOtherLaravelBasicClass {     ...      public function __construct(AnotherUnnecessaryDomainClass $anotherUnnecessaryDomainObject)     {         ...     } }  class AnotherUnnecessaryDomainClass extends OneMoreLaravelBasicClass {     ...      public function __construct(OneMoreUnnecessaryDomainClass $oneMoreUnnecessaryDomainObject)     {         ...     } }

Порождать всю эту цепочку зависимых объектов было бы рискованно (можно было невзначай записать какой-нибудь из них в БД) и хлопотно (надо бы было решать проблему с валидацией и прочими вещами).

Как в таком случае разорвать зависимость и «воткнуть» нужный нам двойник в шаблон? Может есть универсальный способ создать класс с открытыми полями и без конструктора?

Обращение к документации PHP, раздел Reflection, показало, что такая возможность существует. Вариант с анонимным классом был переписан вот так:

$reflection = new ReflectionClass(DataForBladeTemplate::class); // Создаем объект DataForBladeTemplate без вызова конструктора // (знаем, что класс не внутренний, исключений быть не должно). $dataForBladeTemplate = $reflection->newInstanceWithoutConstructor(); // Перебираем переданные в запросе поля и, если такое поле есть  // в классе DataForBladeTemplate, то устанавливаем его значение // с помощью метода setValue объекта ReflectionProperty. // Причём для версии PHP выше 8.1 даже не нужно устанавливать // для поля доступность методом setAccessible, // т.к. оно доступно по-умолчанию. foreach ($request->fields as $field => $fieldValue) {     if (property_exists($dataForBladeTemplate, $fieldName)) {         $classField = $reflection->getProperty($fieldName);         if (PHP_VERSION_ID < 80100) {   // А вдруг )))             $classField->setAccessible(true);         }         $classField->setValue($dataForBladeTemplate, $fieldValue);     } }  ...  return view('view_name', ['data' => $dataForBladeTemplate]);

И снова всё заработало как надо.

Найденное решение привлекает тем, что оно, как и задумывалось, не затрагивает уже работающие участки кода. Вся «магия» совершается только в методах добавляемого контроллера и сервиса формирования бланка. Остальная часть микросервиса ничего не знает об «издевательствах» над его классами. А это уменьшает вероятность поломать работу «чужого» кода.

Конечно, не всегда так просто разорвать слишком плотную связанность объектов. Например, можно представить код, когда в дублируемом объекте в его публичных методах-геттерах прямо или косвенно используется объект, внедрённый в конструкторе. Тогда бы пришлось или переопределять публичные методы-геттеры, или создавать дублёр для внедряемого объекта, или создавать всю цепочку объектов, или, в конце концов, переделывать логику получения данных в шаблоне, как было описано выше.

К счастью, для моего случая ничего этого не потребовалось. Всё закончилось хорошо. Задача была успешно закрыта.


ссылка на оригинал статьи https://habr.com/ru/articles/903334/