Використання TimeProvider у .NET 8 та одного невеликого налаштування SynchronizationContext дозволяє скоротити час виконання юніт-тестів для логіки повторних спроб (retry logic), які інакше простоюють через реальні затримки (sleeps). Тест, який раніше тривав сім секунд, тепер завершується за кілька десятків мілісекунд, причому це нововведення нічого не коштує під час роботи в продакшені.

Чому затримка має значення

Багато допоміжних функцій для повторних спроб використовують експоненціальну затримку (exponential backoff): зачекайте 1 секунду, потім 2 секунди, потім 4 секунди і так далі. У продакшені це захищає сервіси від надмірного навантаження на несправну кінцеву точку (endpoint). У тестовому наборі той самий код викликає Task.Delay на реальному годиннику, тому потік тесту фактично засинає. Помножте це на десятки тестів, і час роботи конвеєра безперервної інтеграції (CI) збільшується на хвилини без жодної функціональної вигоди. Цей «податок на сон» — це просто марнування часу.

Як працює TimeProvider

У .NET 8 було представлено TimeProvider — абстракцію над системним годинником. Клас, якому потрібен поточний час або який має виконувати затримку, може приймати екземпляр TimeProvider замість прямого виклику DateTime.UtcNow або Task.Delay. У продакшені ви передаєте TimeProvider.System, який перенаправляє запити до реального годинника. У тесті ви надаєте FakeTimeProvider. Фейковий провайдер дозволяє вручну переміщувати час за допомогою Advance(TimeSpan). Внутрішньо Task.Delay звертається до провайдера, тому переміщення фейкового годинника вперед миттєво задовольняє будь-яку очікувану затримку.

Ідея проста: замінити реальний годинник керованим, а потім «перестрибнути» вперед до моменту, коли код під тестуванням мав би продовжити роботу.

Пастка SynchronizationContext в xUnit

Ця концепція працює в консольному додатку, але в xUnit вона призвела до взаємоблокування (deadlock). Тест викликав Advance(), поки допоміжна функція повторної спроби очікувала затримку. xUnit встановлює власний SynchronizationContext, який перехоплює продовження (continuations) і запускає їх у потоці тесту. Коли Advance() перемістив годинник вперед, продовження було поставлено в чергу до пулу потоків (thread pool) замість потоку тесту. Потік тесту продовжував просувати час, відправляючи симульований годинник далі моменту, коли має спрацювати наступна повторна спроба. Новий таймер був встановлений на майбутній момент, якого ніколи не вдасться досягти, оскільки не залишилося жодного потоку, щоб знову перемістити годинник. Тест завис назавжди.

Виправлення в один рядок

Додавання одного рядка на початку тесту відновлює очікувану поведінку:

SynchronizationContext.SetSynchronizationContext(null);

Очищення кастомного контексту змушує продовження await виконуватися в пулі потоків, де можуть виконуватися колбеки фейкового таймера. Завдяки цій зміні час виконання того самого тесту скорочується з семи секунд до приблизно 36 мілісекунд.

Ширші переваги

Окрім усунення простоїв через затримки, фейковий годинник дозволяє легко тестувати логіку, чутливу до часових поясів. Ви можете перевірити, що щоденна квота скидається рівно опівночі за місцевим часом, не чекаючи, поки реальний годинник проб'є дванадцяту. Той самий підхід працює для будь-якого коду, що має розгалуження залежно від поточного часу: впровадьте (inject) провайдер, уникайте прихованих викликів Thread.Sleep або Task.Delay, і ви отримаєте детерміновані швидкі тести.

На що варто звернути увагу

  • Кожна затримка має проходити через впроваджений TimeProvider. Випадковий Thread.Sleep або прямий Task.Delay все одно звертатиметься до реального годинника і знову спричинить затримку.
  • Якщо бібліотека, від якої ви залежите, внутрішньо викликає Task.Delay, не надаючи провайдера, ви не зможете контролювати її таймінг без використання більш інвазивних прошарків (shims). У таких випадках вигода може бути обмеженою.
  • Використовуйте grep у папці з тестами для пошуку Task.Delay та Thread.Sleep, щоб виявити приховані очікування. Рефакторинг цих викликів для використання провайдера — це єдиний спосіб зберегти ефективність фейкового годинника.

Підсумок

Заміна системного годинника на TimeProvider та вимкнення SynchronizationContext в xUnit перетворює повільні тести повторних спроб на майже миттєві перевірки, звільняючи ресурси CI та полегшуючи перевірку коду, залежного від часу. Зусилля становлять лише кілька рядків коду, а результат вимірюється секундами, зекономленими під час кожного запуску тестів.