.NET 8 Finally Gives Us A Time Abstraction - TimeProvider

November 29, 2023#Software Development
Article
Author image.

Scott DePouw, Senior Consultant

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.

It's been 84 years

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 given TimeSpan.
  • 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…


Copyright © 2024 NimblePros - All Rights Reserved