If you’re like me, you’ve needed to test business logic surrounding the current time. You’ve probably run into the classic case of having a DateTime.UtcNow
embedded directly into your code (you are using UTC, right?) and having to find a way to inject it for testing purposes. IDateTime
, IClock
, ISystemClock
, or similar are created, allowing you to inject the current time and test to your heart’s content.
Introducing TimeProvider
Finally, in .NET 8, we are no longer saddled with that responsibility. The TimeProvider abstract class gives us the ability to GetUtcNow()
and GetLocalNow()
(both of which return DateTimeOffset, a class you should also be using in place of DateTime
these days). TimeProvider
, being abstract, is able to be mocked as easily as any interface. It comes with its own fake class too, which we’ll show later.
We can finally do away with all of our home-grown implementations of time abstraction and use this standard instead.
TimeProvider
Does More!
It’s beyond the scope of this post, but TimeProvider
provides more goodies than just getting the current time. With it, you can do things like control the behavior of Task.Delay()
to really get down and dirty with your testing. Andrew Lock has a great deep dive into what this abstraction is capable of, and I highly recommend checking it out!
Using TimeProvider in Your Existing Application
Using IDateTime
or similar everywhere already? You can ease into using TimeProvider
by invoking it in your implementation of your abstraction, instead of relying on DateTime
to get the job done:
public class SystemDateTime : IDateTime
{
public DateTime Now => TimeProvider.System.GetLocalNow().DateTime;
public DateTime UtcNow => TimeProvider.System.GetUtcNow().DateTime;
}
TimeProvider.System
is a static instance of SystemTimeProvider
, a concrete implementation of TimeProvider
that is to be used in real code to access the system’s time. In our wrapper above, we replaced DateTime.Now
and DateTime.UtcNow
, respectively, with usages of SystemTimeProvider
instead.
Dependency Injection with TimeProvider
If you’re ready to use TimeProvider
as your abstraction of choice, injection’s a breeze. Register TimeProvider.System
as a singleton instance of TimeProvider
in your IoC container of choice, or directly with .NET 8’s built-in registration:
Built-In Registration
// Program.cs
var builder = new HostApplicationBuilder();
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
IoC Container Example: Autofac
using Autofac;
public class MyAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder
.RegisterInstance(TimeProvider.System)
.As<TimeProvider>()
.SingleInstance();
}
}
Once dependency injection is set up, inject TimeProvider
as you would any other abstraction or interface:
public class ExampleClass
{
private readonly TimeProvider _timeProvider;
public ExampleClass(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public void ExampleMethod()
{
DateTimeOffset rightNow = _timeProvider.GetUtcNow();
}
}
Let’s Test Code with TimeProvider
Say we have a class that depends on the current time. Nothing fancy.
public class DiscountCalculator
{
public decimal CalculateDiscount()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
if(now.Month == 8 && now.Day == 17)
{
// Customer gets 50% off for buying something on this very special day.
return 0.5m;
}
return 0m;
}
}
Trying to test this as-is would be a nightmare. Any tests written to ensure a 50% discount is granted on that August summer day would only pass one day a year! Obviously we need to break this insidious little dependency, and now we don’t have to write a custom abstraction to get the job done. We’ll adjust our calculator to use TimeProvider
now (and just for fun we’ll use C# 12’s new primary constructor syntax):
public class DiscountCalculator(TimeProvider timeProvider)
{
public decimal CalculateDiscount()
{
DateTimeOffset now = timeProvider.GetUtcNow();
if(now.Month == 8 && now.Day == 17)
{
// Customer gets 50% off for buying something on this very special day.
return 0.5m;
}
return 0m;
}
}
FakeTimeProvider
Now since TimeProvider
is an abstraction, you could use, say, NSubstitute
to mock it and return stubbed-in dates. However, in our toolbelt we have FakeTimeProvider
, available via the Microsoft.Extensions.TimeProvider.Testing
NuGet package, which provides a lot of useful tools to fully test the abstraction. Here we’re just going to use it to set the current time, but know that there’s a lot more it can do outside of that! (See Andrew Lock’s post, linked above, for more.)
Let’s write a test for our 50% discount case.
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
public class CalculateDiscount
{
[Fact]
public void Gives50PercentOffOnAugust17th()
{
const decimal expectedDiscount = .5m;
FakeTimeProvider fake = new();
fake.SetUtcNow(new DateTimeOffset(new DateTime(2004, 8, 17)));
DiscountCalculator calculator = new(fake);
decimal discount = calculator.CalculateDiscount();
discount.Should().Be(expectedDiscount);
}
}
And just like that, we’ve assured that the correct discount is applied on the 17th! Moreover this test will always pass, regardless of what the actual current date and time is.
Other Notes on FakeTimeProvider
FakeTimeProvider
’s current time is initialized to January 1st, 2000, midnight UTC.- You can advance the current time instead of setting it, by calling
Advance(TimeSpan delta)
and advancing the clock by a givenTimeSpan
.- Check out the documentation for more methods
- Going back in time is not allowed! This abstraction is designed around the current time, so an exception will be thrown if you try to go backwards in any capacity.
Wrapping Up: Standardizing Under .NET 8’s New Time Abstraction
Once TimeProvider
is in place, you can set out to remove whatever custom abstractions you may have. Like any form of refactoring, time constraints, the size of the project, and more could make this a low priority, and that’s okay! You can ease into using this abstraction, or save it for a fresh new project as time permits. I recommend using TimeProvider
eventually, however, as it’s better to use what’s standard than to use something custom whenever possible, so that fresh eyes on the project have one less thing to learn. (This is why whenever wrapping something behind an abstraction for testing purposes, I recommend mimicking the thing being wrapped in its entirety so different behavior need not be learned. And if new behavior is introduced, make that clear in the method name.) I’m glad that we now have this new abstraction as it gives us one less thing to manage!
Now if they would just do the same thing regarding file system and directory operations…