Правильный поставщик времени для 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 forDateTime
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-пакетов, и не множить зависимости.