TimeProvider and FakeTimeProvider - Improving Recurring Tasks Manager Project

TimeProvider and FakeTimeProvider - Improving Recurring Tasks Manager Project

This post showcases the use of the TimeProvider abstraction in C#, focusing on its two implementations: TimeProvider.System, a replacement for DateTime.UtcNow, and FakeTimeProvider, which allows precise date and time control in unit tests.

The project used as a practice ground is the Recurring Tasks Manager, first introduced in the post Implementing Recurring TasksManager Using C# and AvaloniaUI. A task is that which can be repeatedly completed. Tasks track their creation date, last completion date, and the ideal date range for the next completion.

The project is available on GitHub here. All changes mentioned in this post are part of the pull request here.

Step 1: Time Provider

The first commit introduces the time provider.

The time provider is implemented as a public static property:

public static TimeProvider TimeProvider { get; set; } = TimeProvider.System;

This implementation is chosen for its simplicity. The time provider is static as only one instance is required. It is made public to be replaceable by FakeTimeProvider in tests.

Replacing DateTime with the time provider is straightforward. All instances of DateTime.UtcNow and DateTime.Now are replaced by TimeProvider.GetLocalNow().Date.

Previously, the distinction between local time and UTC (Universal Time Coordinated) was not considered, as evidenced by the inconsistent use of DateTime.UtcNow and DateTime.Now. From now on, local time is used to align with the user's timezone and avoid complex time conversions.

With this flexible time provider in place, the tasks are ready for testing.

Step 2: Task Tests

The second commit implements task tests.

All tests follow the same pattern: action -> result. The action involves either time progression or task completion, while the result reflects the expected consequences of that action.

The task scheduling tests are qualitative (e.g., very early, early, on time, late, very late). These tests ensure the basic scheduling assumptions hold for any scheduler.

Here is an example of the time provider in action:

foreach (int daysPassed in daysBetweenCompletions)
{
	_timeProvider.Advance(TimeSpan.FromDays(daysPassed));
	task.Complete();
}
_timeProvider.Advance(TimeSpan.FromDays(daysSinceLastCompletion));

The time only advances when the time provider is explicitly instructed to do so, which enables building arbitrary task completion histories.

Step 3: Scheduler Tests

The third commit introduces scheduler tests and resolves an off-by-one error in data generation.

These tests differ from those in the previous step as they focus on precise dates when tasks move between Scheduled, Ready, and Overdue states. As such, tweaking the scheduling logic will also certainly require adjustments to these tests.

The tests also reveal an error in data generation, which should produce tasks for each scheduling state but does not. The issue arises due to a combination of the following:

  1. Inconsistent handling of the time component.
  2. Mixup between local time and UTC.
  3. The difference in days between two DateTimes being rounded down, as demonstrated by this self-contained test:
[Test]
public void DifferenceInDaysIgnoresTime()
{
	FakeTimeProvider timeProvider = new();
	timeProvider.SetUtcNow(new DateTimeOffset(2200, 02, 20, 0, 0, 1, TimeSpan.Zero));
	DateTime now = _timeProvider.GetUtcNow().DateTime;
	DateTime tomorrow = now.Date.AddDays(1);
	int difference = (tomorrow - now).Days;
	difference.Should().Be(0);
}

The data generation is corrected by removing the time component and adjusting the creation date.

Step 4: DateOnly

As mentioned earlier, the time component is redundant since scheduling operates in days. A more suitable datatype is DateOnly, introduced in .NET 6 (November 2021). Now that the Task class has been thoroughly tested, it is ready to change.

The fourth commit and the sixth commit together replace all instances of DateTime with DateOnly.

The fifth commit runs a migration using the command dotnet ef migrations add DateOnly. Since both DateTime and DateOnly are stored as TEXT in SQLite, the migration file is empty and thus removed. The fifth commit only updates the TaskContextModelSnapshot.cs file.

Conclusion

This post has showcased the use of the TimeProvider class and addressed a few issues in the Recurring Tasks Manager project.

Read more