Як протестувати спадщину без болю і страху

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

Мені це заощадило величезну купу часу, тому вирішив поділитися. Сподіваюся, кому-то це теж стане корисним. І ще більше сподіваюся, що в світі з кожним днем стає все менше такого коду, де це буде корисно :)

Дякую за увагу.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.