image

Вы получили или пришли на проект, которому d+дцать лет? PHP код был написан в перерывах между охотой на мамонтов и поэтому слегка не читаем? Вам предстоит это как минимум сапортить, как максимум — рефакторить или переписывать?

Если у вас после этих вопросов не участилось дыхание или пульс — проходите мимо, эта статья для тех, кто уже бывал жертвой таких издевательств или предчувствует такой поворот судьбы.

Речь пойдет об одной конкретной задаче, типичной для этой ситуации — покрытии юнит тестами legacy-кода перед его рефактором или изменением. А именно — создание заглушек (моканье, симулирование, etc) для функций и/или методов «на лету».

Хочу предложить решения для следующих двух, как по мне — основных, проблем:

1. Последовательный return для функции-заглушки


public function getSomething($param1, $param2)
{
    $result1 = mysql_query('SELECT * FROM table1');
    // ...
    if ($result1['field'] == $param1) {
        $result2 = mysql_query('SELECT * FROM table2');
    }
    // ...
    if ($result2['field'] == $param2) {
        $result3 = mysql_query('SELECT * FROM table3');
    }
    // ...
    return isset($result3) ? $result3 : $result2;
}

Чтобы покрыть тестом такой код — есть несколько вариантов:

  • Рефактор, вынос запросов, написание абстракции, PDO и тд. Идеально было бы, но покрыть нужно до рефактора, чтобы убедится, что после — все будет работать так же;
  • Mock базы данных. Можно сделать копию базы, «подсунуть» нужные записи. Но что, если таблиц и полей в них десятки, а запросы немного более сложные, чем 2-3 join-а? Дебаг и фабрикация нужных данных может занять дни;
  • Использовать runkit или uopz. Пожалуй, наиболее приемлемый подход в этой ситуации. Но как сделать разный результат для каждого вызова?

2. Выполнение кода, не влияющего на тестируемую функцию


public function sendSomething(array $data)
{
    $ch = curl_init();
    $result = mysql_query('SELECT url FROM info WHERE id = ' . $data['someId']);
    curl_setopt($ch, CURLOPT_URL, $result['url']);
    curl_setopt($ch, CURLOPT_POSTFIELDS, implode('&', $data);
    // ...
    curl_exec($ch);
}
public function myMethod()
{
    $data = SomeCLass::getSomeData();
    // ...
    $data = OtherClass::modifyData($data);
    // ...
    // еще сотня-другая кода, влияющего на содержание массива $data
    // ...
    $this->sendSomething($data);
    // ...
    return $completelyOtherVariable;
}

Варианты:

  • Фиктивный локальный url? Но тогда его нужно «положить» в базу, да и другим членам команды придется поднять такой же локальный хост или коммитить скрипт в доступной «миру» директории текущего хоста… Не самый правильный подход, imho;
  • Переопределить mysql_query и curl_exec через runkit или uopz. Да, но как же узнать, что вообще попало в $data?
  • Переопределить весь метод sendSomething, анонимку «за-bind-ить» в текущую область видимости и посмотреть, что там

Примеры, в основном, «притянуты за уши», но в той или иной степени схожести, по крайней мере в моей практике, такие ситуации встречаются. Да и так нагляднее.

Скорее всего, наиболее безболезненно все это пройдет если выбрать вариант #3 в обоих случаях. Нужно только определиться, что использовать, runkit или uopz? Для меня ответ очевиден потому, что писать php-код в строку и передавать его как параметр — извращение.

Основная функция, которую мы используем, но не нативно:

void uopz_function ( string $class , string $function , Closure $handler [, int $modifiers ] )

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

На этом можно было бы остановиться, потому что любой middle+ программист уже примерно понял, что делать дальше, а junior-у вряд ли поручат такую задачу ввиду высокой вероятности суицида.
Эта статья предназначена только лишь немного ускорить работу каторжника и сделать его код чуть более читабельным и коротким.

Поэтому, хочу предложить вам 2 вещи:

  1. Святая война на тему: «где, как и когда правильно использовать trait-ы»;
  2. Trait-обертка для uopz, где реализовано несколько удобных методов

Дублировать весь код я не буду, просто оставлю здесь ссылку на github. И для удобства кратко перечислю его методы.

uopzFlags($function, $flags); // изменяет флаги
uopzRedefine($constant, $value); // переопределяет константу
uopzFunction($function, Closure $closure, $backup = false); // аналог "чистой" uopz_function за исключением того, что умеет backup-ить и принимать имя функции или метода: 'mysql_query' или ['ClassName', 'methodName']
uopzMuteFunction($function, $backup = false); // просто блокирует выполнение чего-либо, например, если вы не хотите, чтобы какой-то метод отправил письмо при ошибке, или curl не "дергал" url, etc
uopzRestore($function); // восстановление функции из backup-а
uopzBackup($function); // backup функции/метода (удобнее это делать при переопределении)
uopzFunctionSimpleReturn($function, $return, $backup = false); // простая подмена возвращаемого значения. return может быть скаляром, объектом (будет возвращен клон) или анонимной функцией.
uopzFunctionReplace($function, $replace, $backup = false); // замена одной функции другой.
uopzFunctionConsistentReturn($function, array $return, $backup = false); // последовательная замена возвращаемого значения. Нужна в тех случаях, когда точно известна последовательность вызова. Например, если функция вызывается в цикле.
uopzFunctionConditionReturn($function, array $conditionList, $default = null, $backup = false); // возврат значения по условию. Условие состоит из названия аргумента вызываемой функции и его значения.
uopzFunctionHook($function, Closure $closure, &$return, $backup = false); // перехват функции и возврат значения по ссылке.

Ну, и, собственно, решение тех двух проблем с помощью «этого»:

1. Последовательный return


$this->uopzFunctionConsistentReturn('mysql_query', [
    ['id' => 12, 'data' => 'dummy'],
    ['id' => 31, 'data' => 'dummy'],
    ['id' => 45, 'data' => 'dummy'],
]);
// Или, второй способ, с помощью условий (здесь он избыточен, конечно):
$this->uopzFunctionConditionReturn('mysql_query', [
    ['query', 'SELECT * FROM table1', ['id' => 12, 'data' => 'dummy']],
    ['query', 'SELECT * FROM table2', ['id' => 31, 'data' => 'dummy']],
    ['query', 'SELECT * FROM table3', ['id' => 45, 'data' => 'dummy']],
]);

2. Перехват выполнения


$this->uopzFunctionHook(
    ['ClassName', 'sendSomething'],
    function() { return $data; }, // просто возвращаем полученный параметр
    $data // сюда по ссылке мы получим то, что из myMethod передается в sendSomething как $data
);

Мне это сэкономило огромную кучу времени, поэтому — решил поделиться. Надеюсь, кому-то это тоже станет полезным. И еще больше надеюсь, что в мире с каждым днем будет становится все меньше такого кода, где это будет полезно :)

Спасибо за внимание.
Поделиться с друзьями
-->

Комментарии (18)


  1. ErickSkrauch
    25.11.2016 15:59
    +2

    Ваши примеры не отвечают действительности — они читаемые.


    1. ErickSkrauch
      25.11.2016 16:04
      +6

      Должно быть хотя бы как-то так:

      function poschitatchtonibud($abc, $def){
          // Здесь обязательно должно быть подключение к базе дынных, без него никак
          $result1 = mysql_query( "SELECT * FROM table1");
          if ($result1["field"] == $abc) $result2 = mysql_query("Select * FROM table2" );
          if ($result2["field"] == $def)
                                                     $result3 = mysql_query("SELECT * fRom table3");
                          return isset($result3)?$result3:$result2;
      }
      


      1. sumanai
        25.11.2016 18:07
        +1

        подключение к базе дынных

        Именно так!


      1. jced
        25.11.2016 19:30
        +1

        Вы знаете, я минут 10 потратил на то, чтобы понять, что с моим кодом не так и что плохого в том, что примеры читаемы… Сходил выпил чаю, и только прочитав Ваш комментарий раз 5-й, наконец понял, что это сарказм!
        Видимо вечер пятницы сказывается :)


      1. Stalker_RED
        26.11.2016 10:47

        Инструменты автоматического форматирования существуют очень давно, и даже встроены в большинство IDE.


        1. ErickSkrauch
          26.11.2016 10:51
          +1

          Когда это кого останавливало? Как будто вы не работали с джуниорами.


          1. Stalker_RED
            27.11.2016 14:21

            Я не о том. Если вдруг вам приходится читать плохо отформатированный код, что мешает отформатировать его перед тем как читать?


            1. kester
              27.11.2016 22:24

              часто останавливает куча «мусорных» изменений и git blame после них


  1. search
    25.11.2016 18:03
    +3

    Мой любимый паттерн программирования на случай столкновения с легаси кодом: Garbage Wrapper. Только что попробовал погуглить по этому запросу но ничего не нашел. Может он как-то по-другому называется? Но суть: оборачиваем код, который будет в последстивии рефактриться, своим классом и таким образом получаем API, которое в последствии тестируется и вызывается другими методами.


    1. TheGodfather
      25.11.2016 18:46

      На моем проекте как раз похожая ситуация, но пока не могу собраться с силами, чтобы начать рефакторинг. Может, подкинете ссылок, где почитать про этот подход?


      1. search
        30.11.2016 00:37

        Ищу уже четвёртый день и не могу никак найти. Мой товарищ однажды рассказал про этот паттерн и мне название понравилось. Он, наверное, сам его изобрёл.


        1. jced
          30.11.2016 11:26

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


          1. search
            30.11.2016 11:55

            Скорее уж фасад.


            1. jced
              30.11.2016 12:39

              Согласен, «фасад» более подходящий.


    1. Londoner
      25.11.2016 18:48
      +6

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


      1. search
        30.11.2016 00:46

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


  1. alekssamos
    25.11.2016 21:37

    Я практически так и делаю бывает…


  1. vanxant
    26.11.2016 11:14
    +2

    В примерах не хватает глобальных переменных!

    global $USER, $DB;