<? phpukraine СТАТТІ
⌕Пошук по платформі
ТЕСТУВАННЯ 27 вересня 2026 · 8 хв читання

Тести для легасі без тестів: з чого почати і що фіксувати першим

У проєкті нуль тестів, і завтра треба правити розрахунок цін. Unit-тести тут не працюють як перший крок: щоб ізолювати клас, його спершу треба відрефакторити, а рефакторити без тестів ви й не хотіли. Порядок дій інший: характеризаційні тести на HTTP, golden master для розрахунків, шви для залежностей, і лише потім unit.

РP
Редакція phpukraine
Редакція платформи

Типова вхідна точка: репозиторій живе сім років, 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 скаже вам, чи змінилась поведінка. Поки після такого рефакторингу сьют зелений, а ви все одно йдете перевіряти руками - сітки ще немає.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 30 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті