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