Working with time has always been one of the more difficult parts of software development. Inevitably, time makes fools of us all. Luckily, for .NET 8 users, there’s a new attempt to provide tools to make testing your time-based logic much more straightforward.

This post explores the new .NET 8 packages of Microsoft.Bcl.TimerProvider and its testing partner of Microsoft.Extensions.TimeProvider.Testing. Specifically, we’ll see how to access controlled values for Now and UtcNow and how to tick a timer into the future, all through the use of TimeProvider.

Why use a Time Provider?

We’ve all been there, writing business logic that relies on the passage of time. “Execute this method a minute from now, but only if it’s a Monday!” Personally, the challenge of dealing with Date and Time elements in code can be a herculean effort. The packages shipped in .NET 8 attempt to give developers access to a TimeProvider abstraction with methods like GetUtcNow, GetLocalNow, GetTimestamp, and others. These methods provide instances of the DateTimeOffset class, which inherits from DateTime.

In addition to Date and Time helpers, the TimeProvider class provides mechanisms to create Timer instances. In the default implementation, the timer created is of type SystemTimeProviderTimer and implements the ITimer interface. Having the timer instance originate from the TimeProvider allows the testing package to implement a test instance for unit testing.

This layer of indirection allows you to be a time lord, controlling when clocks tick forward, move backward, and when timers should get called. The power to change time can help you test the trickiest of time-based code.

Let’s see a few examples.

Using The Testing Classes

In your codebase, you’re likely to take a dependency on TimeProvider, but in your tests, you’ll be substituting an instance of FakeTimeProvider. It’s a fake time provider.

In my contrived example, I’ve created a method that finds the next day of the week.

public static class DateTimeExtensions
{
    public static DateTime FindNext(this TimeProvider provider, DayOfWeek dayOfWeek)
    {
        var now = provider.GetLocalNow();
        var daysUntilNextWeekDay = ((int)dayOfWeek - (int)now.DayOfWeek + 7) % 7;
        return now.DateTime.AddDays(daysUntilNextWeekDay);
    }
}

Note: Yes, this is a contrived example. I could have passed the DateTime to the method, but that’s not the point.

Let’s create a FakeTimeProvider and set the current date and time.

[Fact]
public void Can_Find_Next_Monday()
{
    var fake = new FakeTimeProvider();
 
    //This is a Wednesday
    fake.SetUtcNow(DateTime.Parse("2023-06-28"));

    var result = fake.FindNext(DayOfWeek.Monday);
    
    Assert.Equal("2023-07-03", result.ToString("yyyy-MM-dd"));
}

Great! That was easy. You must create a new FakeTimeProvider for each appropriate scope. One per test is the safest, but I can also see folks creating one provider for a group of tests. Use your judgment here.

What about Timers? Fake timers use the FakeTimeProvider to know the elapsed time and determine when they should fire. Using the TimeProvider abstraction is helpful for testing callbacks and the infrastructure you may create to manage timers. Let’s look at a complete timer test.

[Fact]
public void Can_tick_when_asked()
{
    var fake = new FakeTimeProvider();
    var now = DateTime.Now;
    fake.SetUtcNow(now);
    
    var result = false;
    
    fake.CreateTimer(
        _ => result = !result,
        state: null, 
        // time to delay before invoking the callback method
        // if TimeSpan.Zero will get immediately invoked
        dueTime: TimeSpan.FromSeconds(1),
        // time between callback invocations
        period: TimeSpan.FromMinutes(1)
    );
    
    Assert.False(result);
    
    // Change the fabric of space & time...
    // well not really.
    fake.SetUtcNow(now.AddMinutes(1));
    
    Assert.True(result);
}

The previous code creates a timer, but we only execute the callback when the time is set a minute into the future. Now we can test our callbacks and if they work as expected with a few lines of code. The dueTime argument is essential, as setting it to TimeSpan.Zero will fire the callback immediately. This behavior may be what you want, but it also means you have no opportunity to test the prior state of your tests.

Here’s the complete unit test to play around with the packages and classes yourself.

using Microsoft.Extensions.Time.Testing;

public class UnitTest1
{
    [Fact]
    public void Can_Find_Next_Monday()
    {
        var fake = new FakeTimeProvider();
     
        // this is a Wednesday
        fake.SetUtcNow(DateTime.Parse("2023-06-28"));
        
        var result = fake.FindNext(DayOfWeek.Monday);
        
        Assert.Equal("2023-07-03", result.ToString("yyyy-MM-dd"));
    }

    [Fact]
    public void Current_Day_Of_Week_Counts_As_Next()
    {
        var fake = new FakeTimeProvider();
     
        // this is a Wednesday
        fake.SetUtcNow(DateTime.Parse("2023-06-28"));

        var result = fake.FindNext(DayOfWeek.Wednesday);
        
        Assert.Equal("2023-06-28", result.ToString("yyyy-MM-dd"));
    }

    [Fact]
    public void Can_tick_when_asked()
    {
        var fake = new FakeTimeProvider();
        var now = DateTime.Now;
        fake.SetUtcNow(now);
        
        var result = false;
        
        fake.CreateTimer(
            _ => result = !result,
            state: null, 
            // time to delay before invoking the callback method
            // if TimeSpan.Zero will get immediately invoked
            dueTime: TimeSpan.FromSeconds(1),
            // time between callback invocations
            period: TimeSpan.FromMinutes(1)
        );
        
        Assert.False(result);
        
        // Change the fabric of space & time...
        // well not really.
        fake.SetUtcNow(now.AddMinutes(1));
        
        Assert.True(result);
    }
    
}

public static class DateTimeExtensions
{
    public static DateTime FindNext(this TimeProvider provider, DayOfWeek dayOfWeek)
    {
        var now = provider.GetLocalNow();
        var daysUntilNextWeekDay = ((int)dayOfWeek - (int)now.DayOfWeek + 7) % 7;
        return now.DateTime.AddDays(daysUntilNextWeekDay);
    }
}

Conclusion

The new Microsoft.Bcl.TimeProvider package and the TimeProvider abstraction should help folks add a layer of separation between them and the DateTime model of .NET. These new classes should alleviate the tight coupling between your business logic and the forward march of time, helping increase the reliability of your codebase. I hope you found this post interesting. Cheers :)