Пишем тесты с временем в .NET 8+
Рассказываем, как тестировать на .NET 8 с использованием временных абстракций в виде TimeProvider и ITimer.
В .NET 8 представили абстракции улучшающие работу со временем.
Немного истории
DateTime
Основной структурой хранения даты и времени является DateTime, которая появилась в одном из первых релизов .NET — версии 1.1.
У структуры стуществует главный недостаток — отсутсвие временной зоны. Для обхода такого неприятного момента к DateTime
добавили поле Kind
со значениями: Local
, Utc
, или Unspecified
. Так, можно получить местное время вызвав DateTime.Now
, где Kind
будет равен Local
. А для перевода в UTC можно воспользоваться вызовом DateTime.Now.ToUniversalTime()
, или что более просто, сразу вернуть DateTime.UtcNow
.
Как же .NET понимает временную зону при переводе из местного времени в UTC? Метод ToUniversalTime берет временную зону из операционной системы. Тогда, если создать на сервере Нью-Йорка экземпляр DateTime.Now
, отправить его в Лондон, а потом на обеих машинах вызвать перевод в UTC, то результат будет разным.
В Microsoft выпустили рекомендацию по этому поводу, переложив всю ответственность на разработчиков:
Разработчик несет ответственность за отслеживание информации о часовом поясе, связанной со значением DateTime через внешние механизмы.
Иными словами сам разработчик должен хранить информацию о часовом поясе, дополнительно к DateTime
.
Альтернативным решением может быть хранение времени только в формате UTC, и последующая конвертация к местному времени на стороне пользователя. К сожалению, это требует дополнительных проверок кода против случайного использования DateTime
с локальным временем, а также не исключает таких нюансов, как изменения правил перехода на зимнее время.
DateTimeOffset
В качестве улучшения в .NET 2 появилась структура DateTimeOffset, которая состоит из:
- структуры DateTime, и
- свойства .Offset — разницы во времени по отношению к UTC.
Описанной проблемы с серверами в разных временных зонах с DateTimeOffset.Now
уже не произойдет.
Но и это не панацея от всех случаев. Допустим в Лондоне для пользователя необходимо сохранить запись к врачу на апрель. Добавляем запись c TimeZoneInfo.FindSystemTimeZoneById("GMT Standard Time")
, и .NET корректно посчитает время DateTimeOffset = 2023-04-01 14:00:00
с Offset = +1
. В конце марта в Британии происходит переход на летнее время и к Offset добавляется +1 час, в а в началае марте Offset = 0
. В некторых странах нет перехода на летнее время, например, в Нигерии Offset
всегда равен +1
. Вроде никаких проблем, но иногда правила перехода меняются, как это было недавно в Европейском союзе. Получается, для клиентов желательно хранить и зону времени с помощью типа TimeZoneInfo
для возможного перерасчета Offset по новым правилам.
Тестирование до .NET 8
Для Unit тестирования важно иметь возможность заменить вызов функции у объекта на свой собственный. Для этого используются Mock’и или Stub’и над абстракциями. Так как DateTime
и DateTimeOffset
не предоставляют инетерфейсов, то можно создать свой, передавать его тип в качестве аргумента функциям, и далее делать Mock’и при тестировании. Например, можно создать такой:
Аналогичную работу сделали в Microsoft, добавив тот же самый код по меньшей мере в 4 разные области .NET.
Какие еще есть решения
Jon Skeet создал библиотеку NodaTime с правильным расчетом времени в самых тонких моментах, и конечно, с поддержкой абстракций.
Хорошо, используем свою или стороннюю абстракцию над временем .NET. А как же проводить интеграционные тесты с внешними библиотеками, которые все еще требуют передачи данных типа DateTime / DateTimeOffset
?
В Microsoft существует инструмент под названием .NET Fakes. Он генерирует Fake’и для любой .NET библиотеки. Например, можно перезаписать статический вызов DateTime.Now
в тестах:
Работает, но есть ограничения. Первое, генератор совместим только с Windows. Второе, генератор включен только в дорогую версию Visual Studio Enterpise. Так, несколько лет назад одной крупной компании пришлось купить лицензию Enterpise всей команде разработчиков и авто-тестировщиков только для покрытия кода тестами с использованием .NET Fakes. Больше никаких возможностей из обширного набора Enterprise версии не использовалось. Браво отделу продаж Microsoft!
Тестируем с .NET 8
После многолетних дебатов и сотней комментариев в .NET 8 RC добавили долгожданные абстракции времени в виде TimeProvider
и ITimer
:
В конце концов, мы ожидаем, что почти никто не будет использовать что-либо кроме TimeProvider.System в эксплуатации. В отличие от многих абстракций, эта особенная: она существует исключительно для тестируемости.
Получилось все же не идеально, но и это большой прогресс.
Ложки дегтя
1. Абстрактный класс вышел громоздким. Если у вас есть метод принимающий в качестве аргумента TimeProvider
, без знания деталей метода нет возможности понять нужно ли делать Mock для GetUtcNow()
или GetLocalNow()
, или даже CreateTimer(...)
при тестировании. Что же в итоге будет вызвано? Разработчики предлагали разбить новый тип на небольшие интерфейсы, в частности аналогичный уже используемому внутри самого .NET:
Но от этой идеи в Microsoft отказались. Причина: идея хранения связанной логики времени в одном месте, даже если испоьзуются только ее небольшие части.
2. Добавили статическое свойство TimeProvider.System
, которое возвращает системный экземпляр TimeProvider
.
Пользователям очень легко использовать такой код, но это ничем не отличается от старого применения статического DateTime.Now
. И так просто без FakeTimeProvider
протестировать код не получится.
Вместо прямого вызова статического экземпляра, ожидается что программисты будут сразу использовать Dipendency Injection:
и далее в ASP.NET Core:
Для новичков это может быть совсем не тривиально.
Хорошие новости
1. Тестирование времени становится более универсальным. Например, можно сделать Mock метода TimeProvider.GetUtcNow
:
2. Команда Mircosoft не стала привносить старую ошибку когда для сторонних эффектов использовались свойства вместо функций. Так ошибочно реализованы DateTime.Now
. При обработке времени TimeProvider
использует только методы: GetUtcNow()
, GetLocalNow()
, GetTimestamp()
, и т.д.
3. Добавили возможность тестировать события таймера через TimeProvider.CreateTimer(...)
и ITimer.Change(...)
. Особенно это актуально для функций Task.Delay(...)
и Task.WaitAsync(...)
, которые также перевели на использование нового TimeProvider
. Разработчики сторонних библиотек хорошо приветствовали на эту новость.
4. Обещают сделать встроенный в .NET класс FakeTimeProvider
для еще большего упрощения тестирования. Тогда возможно отрицательный пункт 2 не будет актуален.
Выводы
Новый класс TimeProvider
предоставляет унифицированную абстракцию времени с возможностью тестирования в экосистеме .NET, пусть и с некоторыми недостатками. Разработчики Microsoft пометили свои предыдущие наработки как устаревшие, и рекомендуют переводить код на TimeProvider
.