Better Controlling Time in .NET

The DateTime type has existed in .NET from the beginning. It represents an instant in time, typically a date and time of day.

However, it has some disadvantages. For example, it doesn’t support time zone handling. DateTimeOffset partially fixes this issue. 

Nevertheless, both DateTime and DateTimeOffset types have static properties. It makes it hard to test the time. 

The community solved this old issue by creating an abstraction for these types to be mocked in unit or integration tests. For example:

    public interface IDateTimeProvider
    {
        DateTime DateTime { get; }
    }

    public class DateTimeProvider : IDateTimeProvider
    {
        public DateTime DateTime => DateTime.UtcNow;
    }

.NET 8 solves this issue by introducing a new type, TimeProvider. But it has more features than the simple abstraction from the sample above. Let’s see what it can do. 

TimeProvider

A new TimeProvider is an abstract class. Don’t worry; you don’t have to implement it if you don’t need it. The TimeProvider has a static property System, which returns the SystemTimeProvider implementation. 

var timeProvider = TimeProvider.System;

Using dependency injection, you can easily register TimeProvider implementation with TimeProvider.System.

The TimeProvider is more powerful than the simple abstractions we created before. It contains LocalTimeZone. You can get DateTimeOffset in UTC and local time zone. 

Console.WriteLine(timeProvider.LocalTimeZone);
// (UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb

Console.WriteLine(timeProvider.GetLocalNow());
// 7/18/2024 23:10:11 +02:00

Console.WriteLine(timeProvider.GetUtcNow());
// 7/18/2024 21:10:11 +00:00

By default, the TimeProvider has a local time zone. But you can easily override it. 

    public class TimeZonedTimeProvider(TimeZoneInfo timeZoneInfo) 
        : TimeProvider
    {
        public override TimeZoneInfo LocalTimeZone => timeZoneInfo;
    }
var timeProvider = new TimeZonedTimeProvider(
    TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"));

Console.WriteLine(timeProvider.LocalTimeZone);
// (UTC-08:00) Pacific Time (US & Canada)

Console.WriteLine(timeProvider.GetLocalNow());
// 7/18/2024 12:24:13 -07:00

Console.WriteLine(timeProvider.GetUtcNow());
// 7/18/ 2024 19:24:13 + 00:00

More importantly, the TimeProvider has a method for creating a timer. It returns ITimer, which was also introduced in .NET 8. 

Also, Task.Delay and Task.WaitAsync methods got the overloads to accept the TimeProvider. 

It allows you to test your timer! Let’s see how.

FakeTimeProvider

Microsoft provides Microsoft.Extensions.TimeProvider.Testing package for testing. It contains FakeTimeProvider implementation for TimeProvider. 

To show FakeTimeProvider in action, let’s assume we have such code. 

public class TimerSample : IAsyncDisposable
{
    private readonly TimeProvider _timeProvider;
    private readonly CancellationTokenSource _cancellationTokenSource;

    public int Value { get; set; } = 0;

    public TimerSample(TimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
        _cancellationTokenSource = new CancellationTokenSource();

        Task.Run(RunInTheLoop);
    }

    private async Task RunInTheLoop()
    {
        var token = _cancellationTokenSource.Token;

        while (!token.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider);
            Value++;
        }
    }

    public async ValueTask DisposeAsync()
    {
        await _cancellationTokenSource.CancelAsync();
    }
}

The TimerSample class accepts TimeProvider in the constructor. When the instance is created, it starts the RunInTheLoop method with Task.Run. The RunInTheLoop method increases the Value property every second in the loop until the cancellation is requested. 

Notice that the FakeTimeProvider has been passed to the Task.Delay method! 

Let’s create a simple test. 

    [Fact]
    public async Task TestTimer()
    {
        FakeTimeProvider fakeTimeProvider = new(DateTimeOffset.Now);

        TimerSample timerSample = new(fakeTimeProvider);

        await Task.Delay(2000);

        Assert.Equal(0, timerSample.Value);

        await timerSample.DisposeAsync();
    }

This test passes. It creates an instance of TimerSample and starts the RunInTheLoop method. Then, we wait for two seconds and assert the Value.

But the expected value is 0, not 2!

That’s because the time in the FakeTimeProvider is not ticking. You control the time!

    [Fact]
    public async Task TestTimer()
    {
        FakeTimeProvider fakeTimeProvider = new(DateTimeOffset.Now);

        TimerSample timerSample = new(fakeTimeProvider);

        var timeBeforeDelay = fakeTimeProvider.GetLocalNow();
        await Task.Delay(2000);
        var timeAfterDelay = fakeTimeProvider.GetLocalNow();

        Assert.Equal(timeBeforeDelay, timeAfterDelay); 
        Assert.Equal(0, timerSample.Value);

        await timerSample.DisposeAsync();
    }

When you equal the time before the delay and after, you will find out that they are the same! 

You need to call the method Advance passing the time delta to change the time.

So, the final test version is the following: 

    [Fact]
    public async Task TestTimer()
    {
        FakeTimeProvider fakeTimeProvider = new(DateTimeOffset.Now);

        TimerSample timerSample = new(fakeTimeProvider);

        Assert.Equal(0, timerSample.Value);

        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));

        Assert.Equal(1, timerSample.Value);

        await timerSample.DisposeAsync();
    }

Summary

The new time abstractions help you better manage time. You no longer need to create custom abstractions for testing purposes. Controlling time is super easy in the tests with FakeTimeProvider.

If you use older .NET versions or the .NET Framework, you can still use these abstractions by installing the NuGet package Microsoft.Bcl.TimeProvider. It implements netstandard 2.0. 

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top