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

от автора

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

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

Спасибо за внимание.
ссылка на оригинал статьи https://habrahabr.ru/post/316140/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *