На главную

Правильный поставщик времени для C#

29-06-2017

Проблема

Покрыть модульными тестами код, который зависит от вызова DateTime.Now или DateTimeOffset.Now. Например, при создании нового поста, фабрика постов заполняет поле дата/время создания:

public class PostFactory
{
    public Post Create(string title, string content)
    {
        return new Post(DateTimeOffset.Now, title, content);
    }
}

Как протестировать, что метод корректно заполняет поле, ведь при каждом вызове DateTime.Now возращает разные значения? Один из способов заключается в том, чтобы спрятать зависимость от недетерменированного вызова в отдельный класс. Он даже имеет устоявшееся название — поставщик времени (time provider).

Классическое решение

public class TimeProvider
{
    public virtual DateTimeOffset Now { get { return DateTimeOffset.Now; } }
}

Мы регистрируем его в контейнере IoC, например, в Autofac:

builder.RegisterType<TimeProvider>()
       .AsSelf()
       .SingleInstance();

И внедряем через конструктор в фабрику постов:

public class PostFactory
{
    private readonly TimeProvider timeProvider;
        
    public PostFactory(TimeProvider timeProvider)
    {
        this.timeProvider = timeProvider;
    }
    
    public Post Create(string title, string content)
    {
        return new Post(timeProvider.Now, title, content);
    }
}

Основное преимущества класса перед DateTime.Now в том, что мы можем превратить его в заглушку:

public class StubTimeProvider : TimeProvider
{
    public DateTimeOffset DefaultNow { get; set; }
        
    public override DateTimeOffset Now => DefaultNow;
}

Эта заглушка всегда возвращает одно и то же значение текущей даты/времени, что не соответствует реальности, зато позволяет нам тестировать код:

[TestMethod]
public void Create_WhenCalled_FillsCreatedAt()
{
    var timeProvider = new StubTimeProvider { DefaultNow = new DateTimeOffset(2017, 06, 29, 17, 32, 10, TimeSpan.Zero) };
    var postFactory = new PostFactory(timeProvider);
        
    var post = postFactory.Create("foo", "bar");
        
    Assert.AreEqual(new DateTimeOffset(2017, 06, 29, 17, 32, 10, TimeSpan.Zero), post.CreatedAt);
}

Проблема классического решения

Поставщик времени оказывается очень фундаментальной штукой, которая нужна практически во всех проектах. Чтобы не дублировать код, вы вынуждены завести специальный общий проект, в котором собираете все такие фундаментальные штуки. И этот проект очень скоро: а) превращается в свалку плохо структурированного кода; б) подключается к каждой вашей программе.

В соответствии с одним из 12-ти факторов, поставщик времени нужно вынести в отдельный проект, и превратить его в NuGet-пакет. Кстати, такой пакет уже существует, но не поддерживает DateTimeOffset, только DateTime.

Microsoft между тем утверждает, что:

These uses for DateTimeOffset values are much more common than those for DateTime values. As a result, DateTimeOffset should be considered the default date and time type for application development.

То есть DateTime это прошлый век, все используем DateTimeOffset. Пора создавать свой собственный пакет. Или нет?

Новое решение

Конечно, нет. Вместо TimeProvider мы можем использовать Func<DateTimeOffset>:

public class PostFactory
{
    private readonly Func<DateTimeOffset> now;
        
    public PostFactory(Func<DateTimeOffset> now)
    {
        this.now = now;
    }
    
    public Post Create(string title, string content)
    {
        return new Post(now(), title, content);
    }
}

Вот так регистрируем функцию в Autofac:

builder.RegisterInstance<Func<DateTimeOffset>(() => DateTimeOffset.Now);

И вот так используем в тестах:

[TestMethod]
public void Create_WhenCalled_FillsCreatedAt()
{
    Func<DateTimeOffset> now = () => new DateTimeOffset(2017, 06, 29, 17, 32, 10, TimeSpan.Zero);
    var postFactory = new PostFactory(now);
        
    var post = postFactory.Create("foo", "bar");
        
    Assert.AreEqual(new DateTimeOffset(2017, 06, 29, 17, 32, 10, TimeSpan.Zero), post.CreatedAt);
}

В результате нам удалось обойтись только стандартными средствами .NET Framework, не создавать NuGet-пакетов, и не множить зависимости.