Note: phiên bản Tiếng Việt của bài này ở link dưới.

https://duongnt.com/time-dependent-unit-testing-vie

Time dependent unit testing in C# with NodaTime

Testing time dependent classes is one of the trickier parts of unit testing. The key requirements for a unit test is fast running time and high consistency. While traditional methods to test time dependent classes often result in slow running and flaky tests.

Fortunately, the NodaTime and NodaTime.Testing package can assist us in resolving this issue.

Traditional ways to test time dependent classes

A sample class

Let’s say we are developing a video game and we have this class to model a combat skill. After users execute this skill, it will enter a 5-second cooldown period.

public class Skill
{
    private const int COOL_DOWN_IN_SECS = 5;
    private DateTime _disableUntilDt;

    public Skill() => _disableUntilDt = new DateTime();

    public string Click()
    {
        var now = DateTime.UtcNow;
        if (now > _disableUntilDt)
        {
            _disableUntilDt = now.AddSeconds(COOL_DOWN_IN_SECS);
            return "Execute special skill";
        }
        else
        {
            return "Skill is in cooldown";
        }
    }
}

Use delay to change the outcome of a method

We can use XUnit to write a unit test to verify that we can’t call Click twice within 5 seconds.

[Fact]
public void ShouldNotUseSkillTwiceWithin5Seconds()
{
    var sut = new Skill();
    // Put the skill into cooldown
    sut.Click();

    // Try to call it again right away
    var rs = sut.Click();

    // Verify that the skill is in cooldown
    Assert.Equal("Skill is in cooldown", rs);
}

But verifying that we can call Click again after the cooldown is much harder. Traditionally, we use a delay here.

[Fact]
public async Task ShouldUseSkillAgainAfterCoolDown()
{
    var sut = new Skill();
    // Put the skill into cooldown
    sut.Click();
    // Wait 6 seconds for the cooldown to expire
    await Task.Delay(6 * 1000);

    // Try to call the skill again
    var rs = sut.Click();

    // Verify that the skill is executed
    Assert.Equal("Execute special skill", rs);
}

Perhaps you can already see the problem. A unit test is supposed to be fast, ideally running in a few milliseconds. But this test takes a whole 6 seconds. Imagine what will happen if this skill has a longer cooldown, or we have to test multiple skills.

Code that is only run at a special point in time

In other scenarios, we can’t even wait until our test becomes correct. Let’s say the skill above has two different animations; a normal version and a special version for Christmas Eve. We add the following method to the Skill class to load the correct animation based on the current time.

public string GetAnimation()
{
    var now = DateTime.UtcNow;
    if (now.Month == 12 && now.Day == 24)
    {
        return "Special animation";
    }
    else
    {
        return "Normal animation";
    }
}

Now, we only have two ways to test this method. Either we wait until it is actually Christmas Eve, or we modify the system clock. Neither are they suitable for unit testing.

Test time dependent classes with NodaTime

Introduce the NodaTime package

NodaTime is a .NET package created to fix some problems with the native DateTime type.

You can install it by running the following command.

dotnet add package NodaTime

And you can install NodaTime.Testing by running the following command.

dotnet add package NodaTime.Testing

Inject the notion of time as a dependency

The first step is to modify Skill‘s constructor to accept an IClock interface. All time related code will be executed via this interface.

private readonly IClock _clock; // IClock is defined in NodaTime

public Skill(IClock clock)
{
    _clock = clock;
    _disableUntilDt = new DateTime();
}

Then, instead of calling DateTime.UtcNow to get the current time, we will use _clock. All the other code to use now can stay the same.

var now = _clock.GetCurrentInstant().ToDateTimeUtc();

With the new dependency injection in place, we can "change" the current time by passing a fake clock object into the constructor of Skill. Normally, we would use a mocking library to create a fake IClock (personally, I usually use the FakeItEasy package). But NodaTime provides a specialized fake called FakeClock. It is defined in the NodaTime.Testing package.

Time travel to Christmas Eve

The constructor of FakeClock accepts an Instant argument. This argument is used to set the result when we call the GetCurrentInstant method. To change the current time to Christmas Eve, all we need to do is pass Christmas Eve to the constructor of FakeClock.

var clock = new FakeClock(Instant.FromUtc(2022, 12, 24, 0, 0, 0)); // We use Christmas Eve 2022

And this is the unit test for GetAnimation.

[Fact]
public void ShouldUseSpecialAnimationOnChristmasEve()
{
    // A fake clock that always returns 2022/12/24 00:00:00
    var clock = new FakeClock(Instant.FromUtc(2022, 12, 24, 0, 0, 0));
    var sut = new Skill(clock);

    // Call the method under test
    var rs = sut.GetAnimation();

    // Because the fake clock is set to Christmas Eve, the return value will be a special animation
    Assert.Equal("Special animation", rs);
}

Speedup skill cooldown

It’s nice to be able to time travel. But if our fake clock always returns the same value, then we can’t actually test the Click method. This is because the cooldown would never expire. Fortunately, FakeClock does support advancing the clock. Moreover, we can control how fast (or slow) the fake clock ticks.

For example, this is how we advance the fake clock by 10 seconds. After the command below, all calls to GetCurrentInstant will return a time 10 seconds after the initial time.

clock.AdvanceSeconds(10);

By using this, we can rewrite ShouldUseSkillAgainAfterCoolDown without the delay. Our test case will take just a few milliseconds instead of 6 seconds.

[Fact]
public void ShouldUseSkillAgainAfterCoolDown()
{
    var startTime = DateTime.UtcNow; // We don't need to fix the time on Christmas Eve anymore
    var clock = new FakeClock(Instant.FromDateTimeUtc(startTime));
    var sut = new Skill(clock);
    // Put the skill into cooldown
    sut.Click();
    // Advance the clock by 6 seconds
    clock.AdvanceSeconds(6);

    // Try to call the skill again
    var rs = sut.Click();

    // Verify that the skill is executed
    Assert.Equal("Execute special skill", rs);
}

The FakeClock has many methods to control the speed of time in high detail. We can advance the clock by seconds, minutes, days,… Please see this link for the complete list.

Always advance the clock by the same amount every time

If we always want to advance the clock by the same amount for every call to GetCurrentInstant, calling AdvanceSeconds every time will get tiresome. In this case, we can pass an autoAdvance argument to the constructor of FakeClock. For example, this is how we create a fake clock that advances 10 seconds at a time.

var startTime = DateTime.UtcNow;
var advanceTenSecs = Duration.FromSeconds(10);
var clock = new FakeClock(Instant.FromDateTimeUtc(startTime), advanceTenSecs);

Then we can simplify ShouldUseSkillAgainAfterCoolDown even further.

[Fact]
public void ShouldUseSkillAgainAfterCoolDown()
{
    var startTime = DateTime.UtcNow;
    var advanceTenSecs = Duration.FromSeconds(10);
    var clock = new FakeClock(Instant.FromDateTimeUtc(startTime), advanceTenSecs);
    var sut = new Skill(clock);
    // Put the skill into cooldown
    sut.Click();

    // Try to call the skill again (clock automatically advances 10 seconds)
    var rs = sut.Click();

    // Verify that the skill is executed
    Assert.Equal("Execute special skill", rs);
}

Use NodaTime to make tests more consistent

Remember that in ShouldNotUseSkillTwiceWithin5Seconds, we assume that the duration between two successive Click commands is less than the cooldown period. This is a pretty safe assumption, because we have a 5 cooldown, which is quite long in CPU time. But what if we shorten the cooldown to just 100ms or even 10ms? Suddenly, our test will become flaky. Sometimes it will fail because the cooldown expires before we can call the second Click.

We can guarantee that this test always passes by specifying how much time passes between two commands.

public void ShouldNotUseSkillTwiceWithin5Seconds()
{
    var startTime = DateTime.UtcNow;
    var clock = new FakeClock(Instant.FromDateTimeUtc(startTime));
    var sut = new Skill(clock);
    // Put the skill into cooldown
    sut.Click();

    // Advance the clock by 5ms (assume the cooldown is now 10ms)
    clock.AdvanceMilliseconds(5);
    // Try to call it again
    var rs = sut.Click();

    // Verify that the skill is in cooldown
    Assert.Equal("Skill is in cooldown", rs);
}

Conclusion

NodaTime and its FakeClock class are my go-to option whenever I need to work with datetime in my projects. It’s a good feeling to not have to jump through hoops to make sure that time-sensitive tests run correctly.

A software developer from Vietnam and is currently living in Japan.

One Thought on “Time dependent unit testing in C#”

Leave a Reply