Типова вхідна точка: репозиторій живе сім років, tests/ або немає, або там три файли з 2019-го, і в п'ятницю треба змінити логіку нарахування знижок. Питання «з чого почати тести» майже завжди отримує відповідь «напиши unit-тест на клас, який правиш». Це найгірший можливий перший крок, і причина не в лінощах.
Чому unit-тести не перший крок
Щоб написати unit-тест, потрібен unit. У легасі клас, який вас цікавить, має конструктор на дев'ять аргументів, звертається до $_SESSION, кличе date() всередині розрахунку і створює new PDO() у приватному методі. Ізолювати його неможливо без рефакторингу, а рефакторинг без тестів це саме те, чого ви намагалися уникнути. Майкл Фізерс назвав це дилемою легасі-коду в «Working Effectively with Legacy Code», і вихід із неї не в тому, щоб почати з меншого шматка.
Друга причина важливіша. Unit-тест, який ви напишете зараз, зафіксує вашу гіпотезу про те, як код мусить працювати:
it('applies a 10% discount for loyal customers', function () {
// а система насправді дає 10% лише якщо остання покупка
// була менш ніж 90 днів тому, і рахує це за UTC
});
Тест впаде, ви подивитесь у код, знайдете там умову з 90 і трьома винятками, і перепишете тест під неї. Тобто зробите характеризаційний тест, але довгим шляхом і з проміжним станом «CI червоний, бо я ще не розібрався». Характеризаційний тест починає з іншого боку: він записує, як система поводиться зараз, разом з усіма дивацтвами, а не як вона мала б поводитись. Далі це стає сіткою безпеки: після рефакторингу ви бачите, чи змінилась поведінка, а не чи збігається вона з вашими уявленнями.
Третя причина - арифметика покриття. Один unit-тест закриває один клас. Один HTTP-тест на сторінку оформлення замовлення проходить через роутинг, мідлвари, контролер, три сервіси, репозиторій і шаблон. На старті це різниця в порядок величини на годину роботи.
Характеризаційні тести на рівні HTTP
Починати треба зовні, з межі, яку легасі не може від вас сховати. У Laravel-проєкті (навіть дуже старому, головне щоб він піднімався в Illuminate\Foundation\Testing\TestCase) це звичайні HTTP-тести. Перший прохід - smoke: список маршрутів і статус-код. Беріть не весь route:list, а те, що реально ходять, за access-логом nginx.
dataset('public_pages', [
['/', 200],
['/catalog?sort=price', 200],
['/order/1042/invoice', 302], // так, зараз редірект, і це фіксуємо
]);
it('keeps the current response status', function (string $uri, int $status) {
$this->get($uri)->assertStatus($status);
})->with('public_pages');
Далі глибше, до тіла відповіді. Порівнювати HTML цілком не вийде, бо в ньому CSRF-токен, дати й випадкові id. Тому перед порівнянням нормалізуйте вихід:
function normalizeHtml(string $html): string
{
return preg_replace(
[
'/name="_token" value="[^"]+"/',
'/\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}/',
'/ id="[a-z]+-[0-9a-f]{8,}"/',
],
['name="_token"', '[DATE]', ' id="[UID]"'],
$html,
);
}
Без фіксованого набору даних нічого з цього не працює. Візьміть дамп продакшену, анонімізуйте його один раз, покладіть у tests/fixtures/baseline.sql і завантажуйте перед сьютом, а кожен тест обертайте в транзакцію через DatabaseTransactions. RefreshDatabase тут не підходить: міграцій на цей дамп у вас, найімовірніше, немає, а ті, що є, не відтворюють реальну схему.
Якщо фреймворку немає взагалі, а є public/index.php і сорок require_once, харнес робиться через вбудований сервер. Symfony\Component\Process\Process піднімає php -S 127.0.0.1:8123 -t public у глобальному beforeAll, а тести стукають туди Guzzle. Виглядає грубо, працює надійно, і перші тридцять тестів ви отримуєте без жодної зміни в легасі-коді.
Коли smoke-сьют зелений, зніміть покриття (php artisan test --coverage-html=build/coverage, PCOV швидший за Xdebug у кілька разів). Це ваша карта: видно, які галузі вже під сіткою, а куди HTTP-тести не дістають узагалі.
Golden master для складних звітів
HTTP-тест погано підходить там, де результат - не сторінка, а XLSX на сорок колонок, PDF-інвойс або нічний перерахунок балансів. Для таких місць працює golden master: ви проганяєте код на наборі входів, зберігаєте вихід у файл і комітите його.
it('builds the same invoice as before', function (array $order) {
Carbon::setTestNow('2026-01-15 12:00:00');
$invoice = (new InvoiceBuilder($this->rates))->build($order);
expect($invoice->toArray())->toMatchSnapshot();
})->with('order_shapes');
Знімки через spatie/pest-plugin-snapshots (або spatie/phpunit-snapshot-assertions з assertMatchesJsonSnapshot(), якщо сьют на PHPUnit). Перший запуск з --update-snapshots створює файли, далі вони працюють як еталон.
Вихід має бути детермінованим, інакше golden master перетвориться на генератор випадкових падінь. Мінімум, який треба закрити: заморожений час, явний timezone і locale у конфізі тестів, фіксований порядок сортування у кожному ORDER BY (без нього MySQL і PostgreSQL віддадуть різне), округлення float перед записом, і вимкнені випадкові ідентифікатори.
Набір входів краще генерувати, а не вигадувати руками. Якщо в розрахунку сім булевих прапорців, напишіть коротку команду, яка перебирає їхні комбінації, кличе білдер і розкладає результати по файлах. Триста golden-файлів у репозиторії - нормально, коли вони згенеровані і відтворюються однією командою.
Окреме правило, яке варто записати в CONTRIBUTING: знімок, що змінився, ніколи не оновлюється «щоб CI позеленів». Зміна знімка у PR означає одне з двох: ви зламали поведінку або змінили її свідомо, і в описі PR має бути сказано, яке саме.
Під час запису ви неминуче знайдете баги. ПДВ округлюється вниз, повернення з минулого місяця не потрапляє у звіт. Не чіпайте їх зараз. Записуйте поточну поведінку як є, з коментарем біля тесту, і ведіть окремий список знайденого. Виправлення поведінки під час накривання тестами позбавляє вас єдиної точки опори: ви більше не знаєте, чи різниця в результаті - це ваш фікс чи ваша помилка.
Шви для підміни залежностей
Шов (seam) - місце, де поведінку можна змінити, не редагуючи код у цьому місці. У PHP практично використовуються три.
Найдешевший - шов у конструкторі. Залежність, яку легасі створює всередині методу, піднімається в поле з дефолтом:
final class OrderProcessor
{
public function __construct(private ?Mailer $mailer = null) {}
private function mailer(): Mailer
{
return $this->mailer ??= new Mailer(config('mail'));
}
}
Продакшен-код, який кличе new OrderProcessor(), не змінюється жодним рядком, тест передає фейк. Це та сама ідея, що й фейки замість моків, просто застосована до коду, який ще не готовий до нормальної інжекції.
Другий - extract and override. Непідмінюваний виклик загортається в protected-метод, а в тестах з'являється підклас:
class ExchangeRates
{
protected function fetch(): string
{
return file_get_contents('https://api.example.test/rates');
}
}
final class ExchangeRatesUnderTest extends ExchangeRates
{
protected function fetch(): string
{
return file_get_contents(__DIR__.'/fixtures/rates.json');
}
}
Умова одна: у винесеному методі не має залишитись логіки. Якщо ви перекрили метод, у якому крім file_get_contents є ще парсинг і валідація, ви тестуєте підклас, а не систему.
Третій шов - на рівні функцій, і він найбрудніший. PHP шукає неквалифіковані виклики функцій спершу в поточному неймспейсі, тому для файла з namespace App\Legacy; можна оголосити App\Legacy\time() у бутстрапі тестів і перехопити виклик. Так працює php-mock/php-mock-phpunit. Не спрацює, якщо в коді написано \time(), і взагалі це тимчасова милиця: як тільки тест зелений, замінюйте на явний годинник - Carbon::setTestNow() у Laravel або MockClock із symfony/clock.
Ready-made шви фреймворку теж варто задіяти одразу: Http::fake(), Queue::fake(), Mail::fake(), Storage::fake(). Працюють вони лише там, де легасі ходить через фасади; прямий curl_exec() або new GuzzleHttp\Client() доведеться виводити швом руками. Щоб знати, де саме, поставте в базовому TestCase Http::preventStrayRequests() - будь-який незамоканий запит через клієнт Laravel почне падати з явною помилкою замість того, щоб тихо ходити в мережу з CI.
План на перший місяць
Перший тиждень іде на харнес, не на тести. Підняти сьют, який запускається однією командою, зібрати анонімізований фікстурний дамп, додати job у CI. Паралельно - інвентаризація: 15-20 точок входу, які реально використовуються, за логами.
Другий тиждень - HTTP-характеризація цих точок: статуси, редіректи, нормалізовані знімки тіла, стан бази після POST. У кінці тижня зняти покриття і подивитись, що лишилось білим.
Третій - golden master на два-три найважчі розрахунки. Тут же з'являється список знайдених, але не виправлених багів.
Четвертий - шви, і тільки в тому модулі, який ви збираєтесь правити. Ось тут нарешті пишуться перші unit-тести, і пишуться вони під код, який ви вже рефакторите, а не під код, який ви ще не розумієте.
Паралельно з першого дня діє одне правило: усе нове приходить зі звичайними тестами. Характеризаційні - виключно для того, що вже існує, і жити вони мають окремо, у tests/Characterization, щоб через рік ніхто не сприйняв їх за специфікацію.
Критерій достатності не в відсотках покриття. Ви зробили достатньо, коли можете перейменувати клас, витягти метод або замінити реалізацію репозиторію в цільовому модулі і CI скаже вам, чи змінилась поведінка. Поки після такого рефакторингу сьют зелений, а ви все одно йдете перевіряти руками - сітки ще немає.