<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ТЕСТУВАННЯ · ОСНОВИ

Час і випадковістьTime and randomness

Тест, що проходить у вівторок і падає в пʼятницю, майже завжди питає поточний час або випадкове значення. Час і випадковість треба передавати в код ззовні, як будь-яку іншу залежність, і тоді тест сам вирішує, яка зараз дата й що випаде.

10 хв читання Middle
01 · ЩО ЦЕ

Залежність, яку не видно

Метод, що викликає now(), new DateTimeImmutable() чи time(), має прихований вхідний параметр: момент запуску. Тест на нього залежить від дня тижня, години, кінця місяця й часового поясу CI. Звідси -тести: правило про відправку до 16:00 зелене зранку й червоне ввечері, розрахунок за місяць ламається 31 числа, а порівняння двох now() падає, коли між ними перемкнулась секунда.

СЛОВНИКFlaky-тест. Тест, що то проходить, то падає на незміненому коді. Час, випадкові дані й порядок тестів серед найчастіших причин.

Найнадійніше рішення: зробити час явною залежністю. Код просить з методом now(), у продакшні контейнер дає SystemClock, а тест створює , який стоїть на заданій миті й рухається лише за командою advance('+7 days'). Для такого порту є стандарт PSR-20, Psr\Clock\ClockInterface, а Symfony Clock має готовий MockClock. Доменний код з Clock тестується без Laravel, тому що час для нього звичайний аргумент.

СЛОВНИКClock. Порт з одним методом now(): DateTimeImmutable. Код бере час лише з нього, тож тест може підставити будь-яку мить.
СЛОВНИКFrozenClock. Реалізація Clock для тестів: показує задану мить і рухається вперед лише через advance(). У Symfony такий клас зветься MockClock.

Код, що вже викликає now() чи Carbon::now(), Laravel дозволяє зупинити в feature-тестах. $this->travel(5)->days() зсуває час уперед, travelTo() ставить конкретну мить, freezeTime() зупиняє годинник, travelBack() повертає справжній. Усі вони працюють через , тож діють лише на Carbon: new DateTimeImmutable(), time() і date() далі показують справжній час, і змішаний код лишається flaky.

СЛОВНИКCarbon::setTestNow(). Статичний метод Carbon, що підміняє поточний час для now(), Carbon::now() і CarbonImmutable::now(). Виклик без аргументу повертає справжній час.

З випадковістю те саме. Str::uuid() у тесті можна зафіксувати через Str::freezeUuids() або Str::createUuidsUsing(). Власну випадковість передавайте як Random\Randomizer, і тест дасть йому рушій із : new Mt19937(42) щоразу видає ту саму послідовність. Випадковим буває й порядок тестів: PHPUnit і Pest уміють перемішувати їх через --order-by=random, і тест, що залежав від сусіда, впаде, а seed з виводу дозволить повторити той самий порядок.

СЛОВНИКSeed. Початкове число генератора псевдовипадкових чисел. Той самий seed дає ту саму послідовність, тож «випадковий» тест можна відтворити.
02 · ВАРІАНТИ

Одне посилання, чотири способи керувати часом

Посилання для скидання пароля діє 60 хвилин. Перша вкладка показує тест, який іноді падає, решта три показують, як прибрати з нього справжній годинник: двома інструментами Laravel і портом Clock.

php ResetLinkTest.php Без контролю
1it('sets the expiry an hour ahead', function () {
2 $link = ResetLink::issue('olena@example.com');
3
4 expect($link->expiresAt->format('Y-m-d H:i:s'))
5 ->toBe(now()->addHour()->format('Y-m-d H:i:s'));
6});
ЩО ПЕРЕВІРЯЄ
Що термін дії на годину попереду. Тест порівнює два різні виклики now(), і якщо між ними перемкнулась секунда, він падає. Локально це майже ніколи, на завантаженому CI раз на кілька сотень запусків.
КОЛИ БРАТИ
Ніколи. Будь-яке порівняння з now() у тесті вимагає зупиненого часу.
php ResetLinkTest.php freezeTime()
1it('sets the expiry an hour ahead', function () {
2 $this->freezeTime();
3
4 $link = ResetLink::issue('olena@example.com');
5
6 expect($link->expiresAt)->toEqual(now()->addHour());
7});
ЩО ПЕРЕВІРЯЄ
Те саме, але годинник зупинено до кінця тесту: обидва now() повертають ту саму мить, з точністю до мікросекунд. Після тесту Laravel сам повертає справжній час.
КОЛИ БРАТИ
Коли код уже викликає now() чи Carbon і треба лише, щоб час не біг під час тесту.
php ResetLinkTest.php travel()
1it('expires the link after an hour', function () {
2 $link = ResetLink::issue('olena@example.com');
3
4 $this->travel(59)->minutes();
5 expect($link->isValid())->toBeTrue();
6
7 $this->travel(2)->minutes();
8 expect($link->isValid())->toBeFalse();
9});
ЩО ПЕРЕВІРЯЄ
Межу терміну дії: за хвилину до кінця посилання ще працює, через хвилину після вже ні. travel() зсуває час відносно поточної миті, тож другий виклик додає ще дві хвилини до вже прожитих 59.
КОЛИ БРАТИ
Для правил, що залежать від минулого часу: термін дії, тротлінг, нагадування через тиждень. travelTo('2026-10-02 23:30') ставить конкретну мить, коли важлива дата, як кінець місяця.
php ResetLinkTest.php FrozenClock
1// tests/Unit/ResetLinkTest.php: без Laravel
2it('expires the link after an hour', function () {
3 $clock = FrozenClock::at('2026-10-02 23:30');
4 $link = ResetLink::issue('olena@example.com', $clock->now());
5
6 $clock->advance('+59 minutes');
7 expect($link->isValidAt($clock->now()))->toBeTrue();
8
9 $clock->advance('+2 minutes');
10 expect($link->isValidAt($clock->now()))->toBeFalse();
11});
ЩО ПЕРЕВІРЯЄ
Те саме правило, але ResetLink отримує час аргументом, а тест бере його з FrozenClock. Тест живе в tests/Unit, не потребує Laravel і заодно перевіряє перехід через північ.
КОЛИ БРАТИ
Для доменного коду й сценаріїв, які ви пишете з нуля. Сценарій бере Clock у конструкторі, а доменний метод отримує мить параметром.
03 · КОД

Годинник і випадковість як залежності

PHP 8.5
1<?php
2
31interface Clock
4{
5 public function now(): DateTimeImmutable;
6}
7
8final class SystemClock implements Clock
9{
10 public function now(): DateTimeImmutable
11 {
12 return new DateTimeImmutable;
13 }
14}
15
16final class FrozenClock implements Clock
17{
18 public function __construct(private DateTimeImmutable $now) {}
19
20 public static function at(string $datetime): self
21 {
22 return new self(new DateTimeImmutable($datetime));
23 }
24
25 public function now(): DateTimeImmutable
26 {
27 return $this->now;
28 }
29
30 public function advance(string $modifier): void
31 {
32 $this->now = $this->now->modify($modifier);
33 }
34}
35
362// Правило: замовлення до 16:00 у будній день їде сьогодні, решта в наступний робочий день.
37final class DispatchDate
38{
39 public function __construct(private Clock $clock) {}
40
41 public function forNewOrder(): string
42 {
43 $day = $this->clock->now();
44
45 if ((int) $day->format('N') <= 5 && (int) $day->format('G') < 16) {
46 return $day->format('Y-m-d');
47 }
48
49 do {
50 $day = $day->modify('+1 day');
51 } while ((int) $day->format('N') > 5);
52
53 return $day->format('Y-m-d');
54 }
55}
56
573// Промокод з випадкових символів. Randomizer приходить ззовні, як і годинник.
58final class PromoCodes
59{
60 public function __construct(private Random\Randomizer $random) {}
61
62 public function next(): string
63 {
64 return $this->random->getBytesFromString('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 8);
65 }
66}
67
68function check(string $what, bool $ok): void
69{
70 echo $ok ? '✓ ' : '✗ ', $what, "\n";
71}
72
734$clock = FrozenClock::at('2026-10-07 10:00'); // середа
74$dispatch = new DispatchDate($clock);
75check('середа 10:00: відправка сьогодні', $dispatch->forNewOrder() === '2026-10-07');
76
77$clock->advance('+7 hours');
78check('середа 17:00: відправка в четвер', $dispatch->forNewOrder() === '2026-10-08');
79
80$clock = FrozenClock::at('2026-10-02 17:00'); // пʼятниця
81check('пʼятниця 17:00: відправка в понеділок', (new DispatchDate($clock))->forNewOrder() === '2026-10-05');
82
835$today = (new DispatchDate(new SystemClock()))->forNewOrder();
84echo "з SystemClock вийшло {$today}, і ця дата залежить від дня запуску\n";
85
866$a = new PromoCodes(new Random\Randomizer(new Random\Engine\Mt19937(42)));
87$b = new PromoCodes(new Random\Randomizer(new Random\Engine\Mt19937(42)));
88check('той самий seed, ті самі коди', [$a->next(), $a->next()] === [$b->next(), $b->next()]);
89
90$code = (new PromoCodes(new Random\Randomizer()))->next();
91check("без seed код щоразу інший, цього разу {$code}", strlen($code) === 8);
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–34

    Clock, SystemClock і FrozenClock рядок у рядок як у src/Shared/Clock PHP Ukraine, лише без namespace і use.

  2. 2 рядки 36–55

    Правило не знає, звідки час. Немає жодного now() чи new DateTimeImmutable() без аргументу.

  3. 3 рядки 57–66

    Випадковість теж залежність: Randomizer у конструкторі, а рушій вирішує той, хто створює обʼєкт.

  4. 4 рядки 73–81

    Середа зранку, середа ввечері, пʼятниця ввечері. Тест сам обирає мить, і результат однаковий щодня.

  5. 5 рядки 83–84

    Те саме правило зі справжнім годинником. Запустіть у суботу, і дата буде іншою.

  6. 6 рядки 86–88

    Два генератори з seed 42 видають ту саму послідовність кодів. Так відтворюють випадкові дані в тесті.

  7. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ЯК У PHP UKRAINE

Вакансія старіє за FrozenClock

Вакансія в PHP Ukraine стає stale, коли джерело тричі поспіль її не показало, і зникає ще через тиждень. Доменний обʼєкт Job отримує час параметром, а тест бере його з FrozenClock і промотує на шість днів, потім ще на один. Чекати тиждень чи підміняти системний час не треба. У feature-тестах той самий FrozenClock кладуть у контейнер: app()->instance(Clock::class, $clock), і весь застосунок живе за ним.

php JobTest.php фрагмент
1it('goes stale after three missed syncs and expires a week later', function () {
2 $clock = FrozenClock::at('2026-09-02 10:00');← час стоїть
3 $job = aJob($clock);
4
5 $job->missedInSync($clock->now());← мить передано параметром
6 $job->missedInSync($clock->now());
7 expect($job->status)->toBe(JobStatus::Active);
8
9 $job->missedInSync($clock->now());
10 expect($job->status)->toBe(JobStatus::Stale)
11 ->and($job->staleSince)->toEqual($clock->now());
12
13 $clock->advance('+6 days');← шість днів за рядок
14 expect($job->expireIfDue($clock->now()))->toBeFalse();
15
16 $clock->advance('+1 day');← межа рівно через тиждень
17 expect($job->expireIfDue($clock->now()))->toBeTrue()
18 ->and($job->status)->toBe(JobStatus::Expired)
19 ->and($job->needsRewrite())->toBeFalse();
20});
05 · ТИПОВІ ПОМИЛКИ

Звідки беруться нестабільні тести

✕ 1

Фіксація часу, яку оминає частина коду

Тест викликає freezeTime(), але сервіс рахує дату через new DateTimeImmutable() чи date(). Carbon зупинено, нативні дати ні, і тест далі залежить від дня запуску.

Натомість: беріть час з одного джерела: now() у Laravel-коді або Clock у домені. Пошук new DateTime без аргументу в src/ швидко покаже, хто оминає годинник.

✕ 2

Дати відносно сьогодні в даних тесту

Дані тесту рахуються від сьогодні: published_at = today()->subDays(60), а код перевіряє межу через новий виклик now(). Якщо набір запустили о 23:59:59, дані живуть ще вчора, перевірка вже сьогодні, і межа зсувається на день.

Натомість: для перевірки межі зупиніть час на конкретній даті через travelTo() або FrozenClock::at() і ставте дати даних від неї.

✕ 3

Випадкові дані, які не відтворити

Фабрика генерує зарплату через fake()->numberBetween(), і тест на медіану іноді падає. Повторити падіння не вдається, бо наступний запуск отримує інші числа.

Натомість: дані, від яких залежить перевірка, задавайте явно. Випадковість лишайте для полів, що на результат не впливають, а власний код з випадковістю тестуйте з Randomizer на seed.

✕ 4

Тест, що залежить від сусіда

Один тест кладе FrozenClock у статичну властивість чи кеш і не прибирає за собою, а наступний мовчки живе в його часі. Поодинці обидва проходять.

Натомість: створюйте годинник у кожному тесті, а статичний стан скидайте після тесту. Час від часу запускайте набір з --order-by=random, щоб такі залежності спливали.

06 · ДЕРЕВО РІШЕНЬ

Як керувати часом у тесті

1
Код, який ви тестуєте, можна змінити?
Свій сценарій чи доменний клас, а не пакет з vendor/ чи код, який зараз нема коли чіпати.
НІ →freezeTime(), travel() або travelTo() у feature-тесті, якщо код бере час через Carbon. Якщо через нативні дати, спершу загорніть їх.
2
Код живе в домені чи сценарії, а не в контролері чи моделі?
Контролери, моделі й Livewire-компоненти так чи інакше живуть у Laravel і тестуються feature-тестами.
НІ →Беріть now() і зупиняйте час у feature-тесті через freezeTime() чи travelTo().
3
Правило залежить від конкретної дати чи проміжку часу?
Термін дії, робочі дні, кінець місяця, тротлінг.
НІ →Годинник не потрібен. Якщо в коді є випадковість, передайте Randomizer і задайте seed у тесті.
ТАК НА ВСІ →Порт Clock у конструкторі сценарію, мить параметром у доменному методі, FrozenClock у тесті.
07 · СЛОВНИК
Flaky-тест
Тест, що то проходить, то падає на незміненому коді. Час, випадкові дані й порядок тестів серед найчастіших причин.
Clock
Порт з одним методом now(): DateTimeImmutable. Код бере час лише з нього, тож тест може підставити будь-яку мить.
FrozenClock
Реалізація Clock для тестів: показує задану мить і рухається вперед лише через advance(). У Symfony такий клас зветься MockClock.
Carbon::setTestNow()
Статичний метод Carbon, що підміняє поточний час для now(), Carbon::now() і CarbonImmutable::now(). Виклик без аргументу повертає справжній час.
Seed
Початкове число генератора псевдовипадкових чисел. Той самий seed дає ту саму послідовність, тож «випадковий» тест можна відтворити.
08 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з тестування →

Бо між викликом у коді й викликом у тесті минає час. Якщо за ці мікросекунди перемкнулась секунда чи день, значення різні. Час треба зупинити: freezeTime() у Laravel або FrozenClock, переданий у код.

travel(5)->days() зсуває час відносно поточної миті, travelTo() ставить конкретну дату, freezeTime() зупиняє годинник на поточній миті. Усі три працюють через Carbon::setTestNow(), тож діють лише на код, що бере час з Carbon, а після тесту Laravel повертає справжній час.

travelTo() діє лише на Carbon і потребує Laravel у тесті. Clock робить час явною залежністю: доменний код тестується як звичайний PHP, а в коді видно, що результат залежить від часу. Ще він працює однаково з будь-яким фреймворком, а PSR-20 дає для нього стандартний інтерфейс.

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