Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров681

На работе была поставлена задача: в главное веб-приложение нашей фирмы добавить метод формирования бланка в формате 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]);

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

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

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

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

Обновление от 27 апреля 2025 года.

Как справедливо напомнил уважаемый @zorn-v100500 PHP разрешает менять сигнатуру конструктора, и можно создать двойник с помощью анонимного класса, не обращая внимания на цепочку порождения доменных объектов:

$dataForBladeTemplate = new class() extends DataForBladeTemplate {

    public string $fieldOne;
    public string $fieldTwo;
    public string $fieldThree;
    public function __construct() {}
};

...
// Заполняем поля этого объекта значениями ... 
foreach ($request->fields as $field => $fieldValue) {
    if (property_exists($dataForBladeTemplate, $field)) {
        $dataForBladeTemplate->$field = $fieldValue;
    }
}  
...
// и передаем в шаблон.
return view('view_name', ['data' => $dataForBladeTemplate]);

И, как предложил @zorn-v100500, можно даже не переобъявлять поля, а сразу переопределять методы, которые используются в процессе формирования шаблона, например, так:

$dataForBladeTemplate = new class($fieldValues) extends DataForBladeTemplate {
    protected Array $fieldValues;

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

    public function getFieldOne()
    {
        return $this->fieldValues['fieldOne'] ?? '';
    }

    public function getFieldTwo()
    {
        return $this->fieldValues['fieldTwo'] ?? '';
    }

     public  function getFieldThree(): string
     {
        return $this->fieldValues['fieldThree'] ?? '';
    }    
};
...
// и передаем в шаблон.
return view('view_name', ['data' => $dataForBladeTemplate]);

Поскольку каждый вариант имеет свои достоинства и недостатки, я решил свести их все в одну таблицу:

Варианты изменений, вносимых в исходный класс

Вариант с рефлексией

Анонимный класс с переопределением конструктора и полей

Анонимный класс с переопределением конструктора и полей

Изначальные трудозатраты

Минимальные (несколько строк кода, вызывающего рефлексию и порождающий экземпляр класса) и цикл, заполняющий поля

Размер код, создающий объект анонимного класса и переопределяющий открытость полей в нём, зависит от числа полей в мокируемом классе

Размер кода анонимного класса зависит от числа переопределяемых методов

В исходный класс добавлено поле, используемое в методах в шаблоне

Добавить поле в клиентский код

Добавить поле в клиентский код и добавить его переопределение в анонимный класс

Если нужно, добавить поле в клиентский код и изменить метод в анонимном классе

В исходный класс добавлено приватное поле, используемое в методах в шаблоне

Добавить поле в клиентский код

Ничего сделать нельзя

Если нужно, добавить поле в клиентский код и изменить метод в анонимном классе

В исходный класс добавлен метод, который используется в шаблоне

Ничего не надо делать

Ничего не надо делать

Добавить метод в анонимный класс

В исходный класс добавлен метод, который не используется в шаблоне (даже косвенно)

Ничего не надо делать

Ничего не надо делать

Ничего не надо делать

Один из разработчиков решил сделать исходный класс финальным (а вдруг, например, по совету Маттиаса Нобака)

Ничего не надо делать

Ничего нельзя сделать

Ничего нельзя сделать

Таким образом, вариант с рефлексией наименее трудозатратный и работает во всех
ситуациях. А вариант с анонимным классом, в котором переопределяются конструктор и методы, обеспечивает наиболее точную настройку данных, передаваемых в шаблон. Но в случае с финальным классом, он бессилен.

Теги:
Хабы:
0
Комментарии5

Публикации

Работа

Ближайшие события