[Из песочницы] Как протестировать наследство без боли и страха
Вы получили или пришли на проект, которому 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 вещи:
- Святая война на тему: «где, как и когда правильно использовать trait-ы»;
- 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
);
Мне это сэкономило огромную кучу времени, поэтому — решил поделиться. Надеюсь, кому-то это тоже станет полезным. И еще больше надеюсь, что в мире с каждым днем будет становится все меньше такого кода, где это будет полезно :)
Спасибо за внимание.
Комментарии (1)
25 ноября 2016 в 15:59
0↑
↓
Ваши примеры не отвечают действительности — они читаемые.