Как протестировать наследство без боли и страха

в 11:07, , рубрики: legacy code, php, refactoring, Программирование, Тестирование веб-сервисов

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, где реализовано несколько удобных методов

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

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

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

Спасибо за внимание.

Автор: jced

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js