Rasmus Olsson

Deterministic Testing in .NET 8 Using TimeProvider

Tags: dotnet,
December 15, 2024

Developers often need to control the current time in tests to ensure consistent results. When using DateTime.UtcNow, test outcomes can vary based on execution time, making them unreliable and non-deterministic.

In this post I will show you an example and how we can make the test deterministic by using a custom implementation as well as the new timeprovider from microsoft that was introduce in .net 8.

Example: A Shipping Service (non-deterministic)

public interface IShippingService { DateOnly GetEstimatedDeliveryDate(bool express); } public class ShippingOptions { public int StandardShippingDays { get; set; } = 3; public int ExpressShippingDays { get; set; } = 1; } public class ShippingService : IShippingService { private readonly ShippingOptions _options = new(); public DateOnly GetEstimatedDeliveryDate(bool express) { var daysToAdd = express ? _options.ExpressShippingDays : _options.StandardShippingDays; // Using the real clock: DateTime.UtcNow var currentDateOnly = DateOnly.FromDateTime(DateTime.UtcNow); return AddBusinessDays(currentDateOnly, daysToAdd); } private static DateOnly AddBusinessDays(DateOnly startDate, int businessDays) { var result = startDate; while (businessDays > 0) { result = result.AddDays(1); if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) { businessDays--; } } return result; } } [Test] public void Should_return_correct_estimated_delivery_date_when_not_express_shipment() { var shippingService = new ShippingService(); var actual = shippingService.GetEstimatedDeliveryDate(express: false); var expected = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(3)); expected.Should().Be(actual); }

As we can see in the code above, the test will pass on certain days, but when it hits a weekend, it will start failing. We need to make the date deterministic by setting a fixed date when the test runs.

Using a custom interface as mock

One simple approach is to use a custom interface that can be mocked. This allows the application to retrieve a fixed date in tests while still using the real-time value in production. Here's an example:

public interface IClock { DateTime UtcNow { get; } } public class SystemClock : IClock { public DateTime UtcNow => DateTime.UtcNow; } public class ShippingService : IShippingService { private readonly ShippingOptions _options = new(); private readonly IClock _clock; public ShippingService(IClock clock) { _clock = clock; } public DateOnly GetEstimatedDeliveryDate(bool express) { var daysToAdd = express ? _options.ExpressShippingDays : _options.StandardShippingDays; // Now we grab the current time from our IClock var currentDateOnly = DateOnly.FromDateTime(_clock.UtcNow); return AddBusinessDays(currentDateOnly, daysToAdd); } private static DateOnly AddBusinessDays(DateOnly startDate, int businessDays) { var result = startDate; while (businessDays > 0) { result = result.AddDays(1); if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) { businessDays--; } } return result; } } [Test] public void Should_return_correct_estimated_delivery_date_when_not_express_shipment() { var clockMock = new Mock<IClock>(); clockMock.Setup(x => x.UtcNow).Returns(new DateTime(2024, 11, 04)); var shippingService = new ShippingService(clockMock.Object); var actual = shippingService.GetEstimatedDeliveryDate(express: false); var expected = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(3)); expected.Should().Be(actual); }

As we can see in the code above, the IClock interface has been injected, and the time is then retrieved. For the text, we mock the interface and returns a predictable time, making the test deterministic.

The .NET 8 TimeProvider Approach

Starting in .NET 8, a custom interface and implementation are no longer required. Microsoft now provides the TimeProvider class as a built-in solution.

public class ShippingService : IShippingService { private readonly TimeProvider _timeProvider; private readonly ShippingOptions _options = new(); public ShippingService(TimeProvider timeProvider) { _timeProvider = timeProvider; } public DateOnly GetEstimatedDeliveryDate(bool express) { var daysToAdd = express ? _options.ExpressShippingDays : _options.StandardShippingDays; var utcNow = _timeProvider.GetUtcNow(); var currentDateOnly = DateOnly.FromDateTime(utcNow.DateTime); return AddBusinessDays(currentDateOnly, daysToAdd); } private static DateOnly AddBusinessDays(DateOnly startDate, int businessDays) { var result = startDate; while (businessDays > 0) { result = result.AddDays(1); if (result.DayOfWeek != DayOfWeek.Saturday && result.DayOfWeek != DayOfWeek.Sunday) { businessDays--; } } return result; } } [TestFixture] public class ShippingServiceTests { [Test] public void StandardShipping_OrderedOnFriday_ReturnsWednesday() { var fridayMorning = new DateTimeOffset(2025, 9, 5, 10, 0, 0, TimeSpan.Zero); var fakeTimeProvider = new FakeTimeProvider(fridayMorning); var shippingService = new ShippingService(fakeTimeProvider); var result = shippingService.GetEstimatedDeliveryDate(express: false); var expected = new DateOnly(2025, 9, 10); Assert.That(result, Is.EqualTo(expected)); } }

Microsoft also provides FakeTimeProvider as part of the Microsoft.Extensions.TimeProvider.Testing NuGet package. It’s essentially a stub implementation of TimeProvider that allows you to control time in your tests, making it easy to set specific dates and times.

Conclusion

It's great to see Microsoft providing standardized solutions for common challenges developers face daily. As developers, we encounter many different approaches to solving the same problem. While this may seem like a small addition, TimeProvider is another step toward more consistent across different .NET projects.

References:

please share