<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
ТЕСТУВАННЯ · MIDDLE ЧАСТО ПИТАЮТЬ

Звідки беруться flaky-тести і як тестувати код, що залежить від часу й випадковості?

Flaky-тест то проходить, то падає на тому самому коді. Найчастіші причини: прихований час (`now()`, `new DateTimeImmutable()`), випадкові дані, залежність від порядку тестів і спільного стану, мережа й паралельність. Час роблять явною залежністю (порт Clock, PSR-20, `FrozenClock`) або зупиняють через `travelTo()`/`freezeTime()`, випадковість передають як `Random\Randomizer` з seed, а порядок перевіряють запуском з `--order-by=random`.

Тест зелений у вівторок і червоний у пʼятницю. Що шукатимете?
Тест падає раз на кілька сотень запусків у CI, локально ніколи. Як шукати причину?
Чому `assertDatabaseHas(['created_at' => now()])` іноді не проходить?
Навіщо інтерфейс Clock, якщо в Laravel є `travelTo()`?
flaky-тести Clock PSR-20 travelTo Randomizer seed

Flaky-тест то проходить, то падає на незміненому коді, і найбільше шкодить звичка, яку він виробляє в команді: тиснути «перезапустити». Причини майже завжди ті самі: час, випадкові дані, порядок тестів і спільний стан, мережа, паралельні процеси. На співбесіді чекають класифікації причин і плану пошуку, а відповідь «додам retry» тут програшна.

Метод, що викликає now(), new DateTimeImmutable() чи time(), має прихований вхідний параметр: момент запуску. Тест на правило «відправка до 16:00» зелений зранку й червоний увечері, правило про кінець місяця падає 31-го. Найнадійніше рішення зробити час явною залежністю. Доменний метод приймає $now параметром або клас отримує в конструктор Clock: PSR-20 описує інтерфейс Psr\Clock\ClockInterface з одним методом now(): DateTimeImmutable. У продакшні контейнер дає системний годинник, у тесті заморожений, який рухається лише за командою; у Symfony такий клас уже є, MockClock.

Для коду, що вже кличе now(), у Laravel є travelTo(), travel(), freezeTime() і freezeSecond(). Вони працюють через Carbon::setTestNow(), тому діють на now(), Carbon::now() і CarbonImmutable::now(), але не на new DateTimeImmutable(), date() чи time(). Звідси підступні падіння: тест заморозив час, а сервіс десь створює нативну дату. Пошук new DateTime без аргументу в src/ швидко показує, хто оминає годинник. Окрема пастка: дані тесту, пораховані від today(), і перевірка через новий виклик now(), що о 23:59:59 опиняються в різних днях.

З випадковістю те саме. Значення, від яких залежить асерт, задають явно, а не беруть з фабрики через fake()->numberBetween(). Власну випадковість передають як Random\Randomizer (PHP 8.2), і тест дає йому рушій з seed: new Mt19937(42) щоразу видає ту саму послідовність. UUID у Laravel фіксують через Str::freezeUuids().

Третя велика група: тести, що залежать від сусідів. Статична властивість, синглтон у контейнері, кеш чи файл, які один тест змінив і не прибрав, роблять результат залежним від порядку. PHPUnit за замовчуванням виконує набір в одному процесі, тож такий стан переживає тест. Запуск з --order-by=random виводить ці залежності на світло, а seed з виводу (--random-order-seed) дозволяє повторити саме той порядок. Розслідування завжди починається з відтворення: ->repeat(500) у Pest на підозрілому тесті, потім пошук джерела, і тільки потім виправлення причини.

use Psr\Clock\ClockInterface;
use Random\Engine\Mt19937;
use Random\Randomizer;

// Час і випадковість приходять ззовні, а не з now() і rand().
final class PromoCodeIssuer
{
    public function __construct(private ClockInterface $clock, private Randomizer $random) {}

    public function issue(): PromoCode
    {
        $code = $this->random->getBytesFromString('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 8);

        return new PromoCode($code, $this->clock->now()->modify('+7 days'));
    }
}

it('issues a code valid for seven days', function () {
    $clock = new FrozenClock(new DateTimeImmutable('2026-10-30 23:59:59'));
    $issuer = new PromoCodeIssuer($clock, new Randomizer(new Mt19937(42)));

    $code = $issuer->issue();

    expect($code->expiresAt)->toEqual(new DateTimeImmutable('2026-11-06 23:59:59'))
        ->and($code->value)->toHaveLength(8);
});

it('gives the same code for the same seed', function () {
    $clock = new FrozenClock(new DateTimeImmutable('2026-10-30'));
    $first = new PromoCodeIssuer($clock, new Randomizer(new Mt19937(42)));
    $second = new PromoCodeIssuer($clock, new Randomizer(new Mt19937(42)));

    expect($first->issue()->value)->toBe($second->issue()->value); // відтворювано
});
Класифікація причин: час, випадковість, порядок і спільний стан (статика, кеш, база), мережа, паралельні процеси, а не «тест просто нестабільний».
Що `now()` це прихований вхідний параметр, і найнадійніше зробити його явним: порт Clock (PSR-20 `Psr\Clock\ClockInterface`), у продакшні системний годинник, у тесті заморожений.
Що `travelTo()`, `freezeTime()` і `travel()` у Laravel працюють через `Carbon::setTestNow()` і не зупиняють `new DateTimeImmutable()`, `time()` чи `date()`.
Що випадковість відтворюють seed-ом: `new Random\Randomizer(new Random\Engine\Mt19937(42))` щоразу дає ту саму послідовність, а дані, від яких залежить перевірка, задають явно.
План розслідування: відтворити (`->repeat()`, `--order-by=random` з seed невдалого запуску), знайти джерело, виправити причину, а не додати retry.
Лікувати flaky-тест повторним запуском у CI чи `sleep(1)` між кроками: причина лишається, а набір стає повільнішим.
Зупинити час через `freezeTime()` і не помітити, що сервіс рахує дату через `new DateTimeImmutable()` або `date()`, які Carbon не контролює.
Рахувати дані тесту від `today()`, а код перевіряти через новий виклик `now()`: о 23:59:59 межа зсувається на день.
Генерувати в фабриці значення, від якого залежить асерт (`fake()->numberBetween()` для зарплати в тесті на медіану), і не мати змоги відтворити падіння.
Класти `FrozenClock` чи підмінений сервіс у статичну властивість і не скидати після тесту: наступний тест мовчки живе в чужому часі.
ПОРАДА

Назвіть `now()` прихованим аргументом методу і покажіть, як зробити його явним: `isExpiredAt(DateTimeImmutable $now)` або Clock у конструкторі. Потім скажіть, що в Laravel-коді, який уже кличе `now()`, є `travelTo()`, але він діє лише на Carbon. Ця різниця і показує, що ви розумієте механіку, а не памʼятаєте хелпер.

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Хелпери часу в Laravel підміняють поточний час лише для Carbon: now(), Carbon::now(), CarbonImmutable::now(). Нативні дати й time() їх не помічають, тому надійніше передавати Clock у код. Повтор у CI ховає причину. PHPUnit за замовчуванням запускає тести в одному процесі, тож статичний стан і кеш переходять між ними.