Flaky-тест майже завжди має одну з трьох причин: код читає системний годинник, кидає кубик або ходить у мережу. Усе інше (порядок рядків без ORDER BY, кеш між тестами) трапляється рідше і правиться точково. Ці три треба лікувати на рівні дизайну, бо інакше вони повертаються щоразу, коли хтось пише новий клас.
Код нижче в стилі phpukraine: PHP 8.3+, Laravel 13, Pest 5, домен без Illuminate.
Годинник як залежність
now() всередині класу означає, що клас має приховану залежність, яку неможливо підмінити, не чіпаючи глобальний стан. Laravel дає travelTo(), freezeTime() і Carbon::setTestNow(); у feature-тестах вони працюють, а базовий TestCase скидає їх у tearDown. Проблеми починаються, коли одиниця, яку ви тестуєте, взагалі не має тягнути Laravel, і коли час впливає на бізнес-правило, а не на timestamp у базі.
Порт мінімальний:
interface Clock
{
public function now(): DateTimeImmutable;
}
Реалізація для продакшену повертає new DateTimeImmutable, тестова тримає зафіксовану мить і вміє її рухати:
final class FrozenClock implements Clock
{
public function __construct(private DateTimeImmutable $now) {}
public static function at(string $datetime): self
{
return new self(new DateTimeImmutable($datetime));
}
public function now(): DateTimeImmutable
{
return $this->now;
}
public function advance(string $modifier): void
{
$this->now = $this->now->modify($modifier);
}
}
Користь дає передусім advance(). З ним пишуться сценарії, де час минає посеред тесту, без жодного sleep():
it('allows a bump once the cooldown has passed', function () {
$clock = FrozenClock::at('2026-03-01 09:00:00');
$posting = Posting::published($clock->now());
expect($posting->canBump($clock->now()))->toBeFalse();
$clock->advance('+7 days');
expect($posting->canBump($clock->now()))->toBeFalse();
$clock->advance('+1 second');
expect($posting->canBump($clock->now()))->toBeTrue();
});
Такий тест перевіряє межу правила, а не «приблизно тиждень». І він не змінює свою поведінку від того, що CI стартував о 23:59:59.
Тут легко спіткнутись на двох речах. new DateTimeImmutable бере таймзону з date_default_timezone_set(), а її Laravel виставляє з app.timezone. Якщо тест форматує дату в рядок і порівнює його, зафіксуйте зону явно, new DateTimeImmutable('2026-03-01 09:00:00', new DateTimeZone('UTC')), інакше локальна машина й CI дадуть різні результати. Друге: беріть $this->clock->now() один раз на початку методу і передавайте далі. Два виклики now() в одному обчисленні дають два різні моменти, і тест на межі періодично блимає.
У feature-тестах порт підміняється через контейнер:
$clock = FrozenClock::at('2026-03-01 09:00:00');
$this->app->instance(Clock::class, $clock);
Далі HTTP-запит, artisan-команда чи Livewire-компонент бачать ту саму мить, включно з класами інфраструктури, які кладуть fetchedAt у базу.
Випадковість: спершу розділіть два її види
Криптографічна випадковість (токени сесій, Str::random(40) для стану OAuth, коди підтвердження) не має ставати передбачуваною ніколи. Її не сідують і не фейкають, а тестують інакше: перевіряють довжину, факт збереження хешу, те, що другий виклик дає інше значення. Якщо ви можете зафіксувати seed і отримати той самий токен, ви зламали безпеку заради зеленого тесту.
Другий вид - випадковість як частина поведінки: jitter у затримці ретраю, вибір варіанта, перемішування списку. Ось її треба виносити за порт так само, як годинник:
interface RandomSource
{
public function int(int $min, int $max): int;
}
final class SystemRandom implements RandomSource
{
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
}
final class SequenceRandom implements RandomSource
{
/** @param list<int> $values */
public function __construct(private array $values) {}
public function int(int $min, int $max): int
{
$value = array_shift($this->values) ?? $min;
return max($min, min($max, $value));
}
}
Тепер клас, який рахує паузу перед повторним запитом до зовнішнього борду, тестується як звичайна функція:
final readonly class RetryDelay
{
public function __construct(private RandomSource $random) {}
public function secondsFor(int $attempt): int
{
$base = min(60, 2 ** $attempt);
return $base + $this->random->int(0, $base);
}
}
it('adds jitter on top of the exponential base', function () {
$delay = new RetryDelay(new SequenceRandom([0, 3, 60]));
expect($delay->secondsFor(1))->toBe(2)
->and($delay->secondsFor(3))->toBe(11)
->and($delay->secondsFor(10))->toBe(120);
});
Окремо про Faker. Фабрики беруть Faker\Generator із контейнера, той самий, що повертає хелпер fake(), тож один рядок у beforeEach робить згенеровані дані повторюваними:
beforeEach(fn () => fake()->seed(20260313));
Це рятує, коли тест раптом впав на конкретному наборі даних: ви бачите seed і відтворюєте падіння локально. Але сідування не робить тест нормальним, якщо він перевіряє те, що фабрика вигадала. Значення, яке потрапляє в expect(), передавайте у фабрику явно: Job::factory()->create(['title' => 'PHP Developer']). Seed тут страховка на випадок, коли невдалі дані впливають на щось третє, наприклад на довжину рядка чи на унікальність slug.
Зовнішній API: один контрактний тест, далі фейк порту
Http::fake() виглядає універсальним рішенням, і в цьому пастка. Він підміняє транспорт, тому тест знає про URL, про структуру JSON і про те, що клієнт узагалі ходить по HTTP. Коли таке знання розповзається по двадцятьох тестах use case, зміна ендпоїнта ламає двадцять файлів, жоден з яких про той ендпоїнт не мав би знати.
Поділ такий. Адаптер (клас в Infrastructure, який реалізує порт) отримує один-два тести з Http::fake() і збереженою відповіддю реального API. Це контрактний тест: він фіксує, як ви розбираєте чужий JSON і що робите, коли сервіс віддає помилку.
Http::fake([
'packagist.org/*' => Http::sequence()
->push(json_decode(file_get_contents(__DIR__.'/fixtures/packagist-laravel.json'), true))
->whenEmpty(Http::response('', 503)),
]);
$stats = new PackagistRegistry(new BoardClient, FrozenClock::at('2026-03-13 10:00:00'));
expect($stats->fetch('laravel/framework')->latestVersion)->toBe('13.17.0');
expect(fn () => $stats->fetch('laravel/framework'))->toThrow(RuntimeException::class);
Http::sequence() з whenEmpty() дає перевірити другий випадок безкоштовно: перша відповідь нормальна, далі сервіс лежить. Саме так ви дізнаєтесь, чи деградує ваш код у щось притомне, чи валить сторінку.
Усе, що вище адаптера, працює з фейком порту:
final class FakePackageRegistry implements PackageRegistry
{
/** @var array<string, PackageStats> */
private array $known = [];
public function add(PackageStats $stats): void
{
$this->known[$stats->name] = $stats;
}
public function fetch(string $name): PackageStats
{
return $this->known[$name] ?? throw new RuntimeException("Unknown package {$name}.");
}
}
Тест use case «оновити картки пакетів» більше не містить ні URL, ні JSON, ні коду відповіді. Він містить те, що насправді перевіряє: скільки карток оновилось і що сталося з пакетом, якого в реєстрі немає. Аргументи на користь фейків проти моків розібрані окремо, тут важливий лише рівень, на якому підміна відбувається: порт, а не сокет. Деталі про самі фейки є в статті Фейки замість моків.
Щоб випадковий забутий адаптер не поліз у справжню мережу на CI, увімкніть у tests/Pest.php жорсткий режим:
Http::preventStrayRequests();
Будь-який HTTP-запит без відповідного Http::fake() тепер кидає виняток із URL у повідомленні. Це швидше, ніж шукати, чому сьюта раптом виконується три хвилини замість сорока секунд.
Що лишається після цього
Три порти прибирають більшість плавучих падінь, але не всі. Запити без ORDER BY повертають рядки в порядку, який MySQL не обіцяє, і на CI він інший. Кеш і конфіг протікають між тестами, якщо одна група пише config([...]), а інша на це розраховує. Паралельні воркери діляться чимось глобальним: файлами в storage/app, таблицею без транзакції, зовнішнім сервісом. Окремо стоїть now() у Blade або в скоупі моделі: жодний архітектурний тест його не зловить, бо це не домен.
Правило, яке економить час: щойно тест впав раз і зійшовся з повторного запуску, не перезапускайте його вдруге. Знайдіть, який із трьох входів (час, випадковість, мережа) у ньому не зафіксований, і зафіксуйте. Тест, який зеленіє через раз, гірший за відсутній, бо команда вчиться ігнорувати червоний CI.