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

Час, випадковість і зовнішні API в тестах: як зробити їх передбачуваними

Тест, який падає раз на тиждень о 00:59 або коли packagist.org відповідає повільно, нічого не перевіряє. Розбираємо три джерела недетермінізму і три способи їх прибрати: порт Clock замість now(), джерело випадковості за інтерфейсом і фейк порту замість Http::fake у кожному тесті.

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

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.

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