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

Почему задержка имеет значение

Многие вспомогательные функции для повторных попыток используют экспоненциальную задержку (exponential backoff): подождать 1 секунду, затем 2 секунды, затем 4 секунды и так далее. В продакшене это защищает сервисы от чрезмерной нагрузки на неисправную конечную точку. В тестовом наборе тот же код вызывает 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 выполняться в пуле потоков, где могут исполняться обратные вызовы (callbacks) фейкового таймера. С этим изменением тот же самый тест сокращается с семи секунд до примерно 36 миллисекунд.

Более широкие преимущества

Помимо устранения простоев из-за сна, фейковые часы упрощают тестирование логики, чувствительной к часовым поясам. Вы можете проверить, что ежедневная квота сбрасывается в правильную полночь по местному времени, не дожидаясь, пока реальные часы пробьют двенадцать. Тот же подход работает для любого кода, использующего текущее время в условиях ветвления: внедрите провайдер, избегайте скрытых вызовов Thread.Sleep или Task.Delay, и вы получите детерминированные и быстрые тесты.

На что стоит обратить внимание

  • Каждая задержка должна проходить через внедренный TimeProvider. Случайный Thread.Sleep или прямой вызов Task.Delay по-прежнему будут обращаться к реальным часам и снова вносить задержки.
  • Если библиотека, от которой вы зависите, внутри себя вызывает Task.Delay, не предоставляя провайдер, вы не сможете контролировать её тайминги без более инвазивной прослойки (shim). В таких случаях выгода может быть ограниченной.
  • Используйте grep в папке с тестами для поиска Task.Delay и Thread.Sleep, чтобы обнаружить скрытые ожидания. Рефакторинг этих вызовов с использованием провайдера — единственный способ сохранить эффективность фейковых часов.

Итог

Замена системных часов на TimeProvider и отключение SynchronizationContext в xUnit превращает медленные тесты повторных попыток в почти мгновенные проверки, высвобождая ресурсы CI и упрощая верификацию кода, зависящего от времени. Усилия заключаются всего в нескольких строках кода, а результат измеряется секундами, сэкономленными при каждом запуске тестов.