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
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.
One Thought on “Time dependent unit testing in C#”