Майкл Фезерс (Michael Feathers) у книжці «Working Effectively with Legacy Code» (2004) визначив легасі просто: це код без тестів. Біда такого коду в тому, що будь-яка зміна стає ставкою: ніхто не знає, що саме зламається. Тому першим кроком іде сітка безпеки з тестів, що фіксують поточну поведінку, і лише потім рефакторинг чи нова фіча.
Такі тести Фезерс назвав characterization tests. Вони описують те, що код робить зараз, а не те, що він мав би робити. Техніка механічна: викликати код з реалістичними даними, поставити свідомо неправильне очікування, запустити й переписати очікування з повідомлення про падіння. Кілька десятків таких випадків, включно з нулями, порожніми рядками й рідкісними статусами, дають набір, який кричить при будь-якій зміні поведінки. Якщо серед результатів трапився явний баг, його не виправляють мовчки: позначають, узгоджують з бізнесом і виправляють окремою зміною, бо на нього вже може спиратися звіт чи інша система.
Коли до логіки всередині не дістатися, бо клас сам створює PDO, кличе date() і статичні хелпери, фіксують зовнішню межу: HTTP-відповідь, вивід консольної команди, згенерований файл. Це golden master, або snapshot. У Pest є toMatchSnapshot(), що зберігає еталон при першому запуску й порівнює з ним далі. Нестабільні частини (дати, CSRF-токени, автоінкрементні id) нормалізують або зупиняють через travelTo(), інакше еталон не збігатиметься ніколи.
Далі шукають шви (seams): місця, де поведінку можна змінити без редагування коду в цьому місці. Параметр конструктора замість new всередині, перевизначений метод у тестовому підкласі, підміна біндінгу в контейнері Laravel. Перші зміни роблять маленькими й безпечними, бажано автоматичними рефакторингами IDE: витягти метод, передати залежність через конструктор, загорнути date() у Clock. Під захистом characterization-тестів це вже не ставка.
Нову поведінку пишуть не всередині старого класу, а поруч. Sprout method або sprout class: логіка живе в новому коді з повноцінними unit-тестами, а в легасі зʼявляється один рядок виклику. Wrap method: старий метод перейменовують, новий з тим самим імʼям викликає його й додає поведінку. Так нетестована частина поступово стискається, а характеризаційні тести, що виконали свою роль, замінюють на звичайні тести поведінки, які описують правила, а не історичний вивід.
// Легасі: розрахунок комісії всередині старого сервісу, без тестів.
// Спершу фіксуємо, що він робить ЗАРАЗ, на реальних вхідних даних.
it('keeps the current commission rules', function (int $amount, string $status, int $expected) {
$service = new LegacyBillingService(); // конструктор без залежностей, пощастило
expect($service->commission($amount, $status))->toBe($expected);
})->with([
// Очікування взяті з виводу самого коду, а не з вимог.
'звичайний платіж' => [10_000, 'paid', 250],
'нуль' => [0, 'paid', 0],
'повернення' => [10_000, 'refunded', 500], // підозріло: комісія вдвічі? TICKET-123
]);
// Коли всередину не зайти, фіксуємо зовнішній вивід цілком (golden master).
it('renders the same invoice page', function () {
$this->travelTo('2026-10-01 10:00'); // дата в рахунку стабільна
$html = $this->get('/invoices/42')->assertOk()->getContent();
// Токени й id, що змінюються між запусками, нормалізуємо.
$html = preg_replace('/name="_token" value="[^"]+"/', 'name="_token" value="X"', $html);
expect($html)->toMatchSnapshot();
});
Відповідайте як план на перший день: «зафіксую поточну поведінку тестом на рівні HTTP чи команди з кількома реальними вхідними даними, потім знайду шов для дати й бази, винесу розрахунок комісії в окремий клас з unit-тестами, і тільки тоді зміню правило». Слова «поточна поведінка, навіть якщо вона неправильна» показують досвід роботи з легасі.