Пишем тесты с временем в .NET 8+

Аватарка пользователя Artur Ampilogov

Рассказываем, как тестировать на .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’и при тестировании. Например, можно создать такой:

			public interface ISystemClock
{
   DateTimeOffset UtcNow { get; }
}
		

Аналогичную работу сделали в Microsoft, добавив тот же самый код по меньшей мере в 4 разные области .NET.

Какие еще есть решения

Jon Skeet создал библиотеку NodaTime с правильным расчетом времени в самых тонких моментах, и конечно, с поддержкой абстракций.

Хорошо, используем свою или стороннюю абстракцию над временем .NET. А как же проводить интеграционные тесты с внешними библиотеками, которые все еще требуют передачи данных типа DateTime / DateTimeOffset?

В Microsoft существует инструмент под названием .NET Fakes. Он генерирует Fake’и для любой .NET библиотеки. Например, можно перезаписать статический вызов DateTime.Now в тестах:

			System.Fakes.ShimDateTime.NowGet = () => { return new DateTime(2025, 12, 31); };
		

Работает, но есть ограничения. Первое, генератор совместим только с Windows. Второе, генератор включен только в дорогую версию Visual Studio Enterpise. Так, несколько лет назад одной крупной компании пришлось купить лицензию Enterpise всей команде разработчиков и авто-тестировщиков только для покрытия кода тестами с использованием .NET Fakes. Больше никаких возможностей из обширного набора Enterprise версии не использовалось. Браво отделу продаж Microsoft!

Тестируем с .NET 8

После многолетних дебатов и сотней комментариев в .NET 8 RC добавили долгожданные абстракции времени в виде TimeProvider и ITimer:

			public abstract class TimeProvider
{
    public static TimeProvider System { get; }
    protected TimeProvider()
    public virtual DateTimeOffset GetUtcNow()
    public DateTimeOffset GetLocalNow()
    public virtual TimeZoneInfo LocalTimeZone { get; }
    public virtual long TimestampFrequency { get; }
    public virtual long GetTimestamp()
    public TimeSpan GetElapsedTime(long startingTimestamp)
    public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp)
    public virtual ITimer CreateTimer(TimerCallback callback, object? state,TimeSpan dueTime, TimeSpan period)
}

public interface ITimer : IDisposable, IAsyncDisposable
{
    bool Change(TimeSpan dueTime, TimeSpan period);
}
		
В конце концов, мы ожидаем, что почти никто не будет использовать что-либо кроме TimeProvider.System в эксплуатации. В отличие от многих абстракций, эта особенная: она существует исключительно для тестируемости.

Получилось все же не идеально, но и это большой прогресс.

Ложки дегтя

1. Абстрактный класс вышел громоздким. Если у вас есть метод принимающий в качестве аргумента TimeProvider, без знания деталей метода нет возможности понять нужно ли делать Mock для GetUtcNow() или GetLocalNow(), или даже CreateTimer(...) при тестировании. Что же в итоге будет вызвано? Разработчики предлагали разбить новый тип на небольшие интерфейсы, в частности аналогичный уже используемому внутри самого .NET:

			public interface ISystemClock
{
    public DateTimeOffset GetUtcNow();
}

public abstract class TimeProvider: ISystemClock {
    // ...
}
		

Но от этой идеи в Microsoft отказались. Причина: идея хранения связанной логики времени в одном месте, даже если испоьзуются только ее небольшие части.

2. Добавили статическое свойство TimeProvider.System, которое возвращает системный экземпляр TimeProvider.

Пользователям очень легко использовать такой код, но это ничем не отличается от старого применения статического DateTime.Now. И так просто без FakeTimeProvider протестировать код не получится.

Вместо прямого вызова статического экземпляра, ожидается что программисты будут сразу использовать Dipendency Injection:

			public class MyService
{
    public readonly TimeProvider _timeProvider;


    public MyService(String timeProvider){
        _timeProvider = timeProvider;
    }


    public boolean IsMonday() {
    // использование _timeProvider.GetLocalNow()   
    }         
}
		

и далее в ASP.NET Core:

			var builder = WebApplication.CreateBuilder();

// по умолчанию добавляем системный экземпляр, а в тестах его перезапишем
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton()
		

Для новичков это может быть совсем не тривиально. 

Хорошие новости

1. Тестирование времени становится более универсальным. Например, можно сделать Mock метода TimeProvider.GetUtcNow:

			using Moq;
using NUnit.Framework;

[Test]
public void MyTest()
{
    var mock = new Mock();
    mock.Setup(x => x.GetLocalNow()).Returns(new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero));
    var mockedTimeProvider = mock.Object;


    var myService = new MyService(mockedTimeProvider);
    var result = myService.IsMonday(mockedTimeProvider);
    Assert.IsTrue(result, "Should be Monday");
}
		

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.

371