Залежність, яку не видно
Метод, що викликає now(), new DateTimeImmutable() чи time(), має прихований вхідний параметр: момент запуску. Тест на нього залежить від дня тижня, години, кінця місяця й часового поясу CI. Звідси -тести: правило про відправку до 16:00 зелене зранку й червоне ввечері, розрахунок за місяць ламається 31 числа, а порівняння двох now() падає, коли між ними перемкнулась секунда.
Найнадійніше рішення: зробити час явною залежністю. Код просить з методом now(), у продакшні контейнер дає SystemClock, а тест створює , який стоїть на заданій миті й рухається лише за командою advance('+7 days'). Для такого порту є стандарт PSR-20, Psr\Clock\ClockInterface, а Symfony Clock має готовий MockClock. Доменний код з Clock тестується без Laravel, тому що час для нього звичайний аргумент.
Код, що вже викликає now() чи Carbon::now(), Laravel дозволяє зупинити в feature-тестах. $this->travel(5)->days() зсуває час уперед, travelTo() ставить конкретну мить, freezeTime() зупиняє годинник, travelBack() повертає справжній. Усі вони працюють через , тож діють лише на Carbon: new DateTimeImmutable(), time() і date() далі показують справжній час, і змішаний код лишається flaky.
З випадковістю те саме. Str::uuid() у тесті можна зафіксувати через Str::freezeUuids() або Str::createUuidsUsing(). Власну випадковість передавайте як Random\Randomizer, і тест дасть йому рушій із : new Mt19937(42) щоразу видає ту саму послідовність. Випадковим буває й порядок тестів: PHPUnit і Pest уміють перемішувати їх через --order-by=random, і тест, що залежав від сусіда, впаде, а seed з виводу дозволить повторити той самий порядок.
Одне посилання, чотири способи керувати часом
Посилання для скидання пароля діє 60 хвилин. Перша вкладка показує тест, який іноді падає, решта три показують, як прибрати з нього справжній годинник: двома інструментами Laravel і портом Clock.
Годинник і випадковість як залежності
-
1
рядки 3–34
Clock, SystemClock і FrozenClock рядок у рядок як у src/Shared/Clock PHP Ukraine, лише без namespace і use.
-
2
рядки 36–55
Правило не знає, звідки час. Немає жодного now() чи new DateTimeImmutable() без аргументу.
-
3
рядки 57–66
Випадковість теж залежність: Randomizer у конструкторі, а рушій вирішує той, хто створює обʼєкт.
-
4
рядки 73–81
Середа зранку, середа ввечері, пʼятниця ввечері. Тест сам обирає мить, і результат однаковий щодня.
-
5
рядки 83–84
Те саме правило зі справжнім годинником. Запустіть у суботу, і дата буде іншою.
-
6
рядки 86–88
Два генератори з seed 42 видають ту саму послідовність кодів. Так відтворюють випадкові дані в тесті.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Вакансія старіє за FrozenClock
Вакансія в PHP Ukraine стає stale, коли джерело тричі поспіль її не показало, і зникає ще через тиждень. Доменний обʼєкт Job отримує час параметром, а тест бере його з FrozenClock і промотує на шість днів, потім ще на один. Чекати тиждень чи підміняти системний час не треба. У feature-тестах той самий FrozenClock кладуть у контейнер: app()->instance(Clock::class, $clock), і весь застосунок живе за ним.
Звідки беруться нестабільні тести
Фіксація часу, яку оминає частина коду
Тест викликає freezeTime(), але сервіс рахує дату через new DateTimeImmutable() чи date(). Carbon зупинено, нативні дати ні, і тест далі залежить від дня запуску.
Натомість: беріть час з одного джерела: now() у Laravel-коді або Clock у домені. Пошук new DateTime без аргументу в src/ швидко покаже, хто оминає годинник.
Дати відносно сьогодні в даних тесту
Дані тесту рахуються від сьогодні: published_at = today()->subDays(60), а код перевіряє межу через новий виклик now(). Якщо набір запустили о 23:59:59, дані живуть ще вчора, перевірка вже сьогодні, і межа зсувається на день.
Натомість: для перевірки межі зупиніть час на конкретній даті через travelTo() або FrozenClock::at() і ставте дати даних від неї.
Випадкові дані, які не відтворити
Фабрика генерує зарплату через fake()->numberBetween(), і тест на медіану іноді падає. Повторити падіння не вдається, бо наступний запуск отримує інші числа.
Натомість: дані, від яких залежить перевірка, задавайте явно. Випадковість лишайте для полів, що на результат не впливають, а власний код з випадковістю тестуйте з Randomizer на seed.
Тест, що залежить від сусіда
Один тест кладе FrozenClock у статичну властивість чи кеш і не прибирає за собою, а наступний мовчки живе в його часі. Поодинці обидва проходять.
Натомість: створюйте годинник у кожному тесті, а статичний стан скидайте після тесту. Час від часу запускайте набір з --order-by=random, щоб такі залежності спливали.
Як керувати часом у тесті
- Flaky-тест
- Тест, що то проходить, то падає на незміненому коді. Час, випадкові дані й порядок тестів серед найчастіших причин.
- Clock
- Порт з одним методом now(): DateTimeImmutable. Код бере час лише з нього, тож тест може підставити будь-яку мить.
- FrozenClock
- Реалізація Clock для тестів: показує задану мить і рухається вперед лише через advance(). У Symfony такий клас зветься MockClock.
- Carbon::setTestNow()
- Статичний метод Carbon, що підміняє поточний час для now(), Carbon::now() і CarbonImmutable::now(). Виклик без аргументу повертає справжній час.
- Seed
- Початкове число генератора псевдовипадкових чисел. Той самий seed дає ту саму послідовність, тож «випадковий» тест можна відтворити.
Питання зі співбесід
Бо між викликом у коді й викликом у тесті минає час. Якщо за ці мікросекунди перемкнулась секунда чи день, значення різні. Час треба зупинити: 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, спільний стан між тестами, мережу. Виправляю причину в коді чи тесті, а не ставлю повтор при падінні.