DateTime.Now is not [deterministically] testable
Unit testing a method that relies on DateTime.Now is generally a challenge. Take the below example, which was the first version of a method to get the Current Price of a Product. The business rule for GetCurrentPrice() is simple: find the most recent price where the effective date is not in the future.
For this scenario, imagine the user sets the Price of a product to $25,000 but that price doesn’t take effect until the next year. Until that time, we must make sure that the system returns the previous (existing) price.
The poorly testable method
public decimal GetCurrentPrice()
{
var priceHistory = PriceHistories
.Where(ph.EffectiveDate.Date <= DateTime.Now.Date)
.OrderByDescending(ph => ph.EffectiveDate)
.Take(1)
.SingleOrDefault();
return (priceHistory != null) ? priceHistory.Price : 0m;
}
Trying to simulate our test case scenario mentioned above, we would have to use some (albeit simple, but fragile) DateTime manipulation. We could arrange our test case by adding a PriceHistory to the product, set the effective date to DateTime.Today.AddDays(-7). We could then add a second PriceHistory and set its effective date to DateTime.Today.AddYears(1). This would actually work, we could Assert that the Price that was set to the Effective Date of 7 days ago is the Current Price of the product. The test would pass, probably every time. I say “probably” because DateTime.Today is going to return a different value every single time the test is executed – effectively meaning that a different test is actually running every time. These types of tests quickly get the reputation for false-negatives. You know these types of tests – tests that pass most of the time but every once in a while they will fail for some unknown reason. Usually, when these tests fail it is ignored, even if a bug was actually introduced into the method under test.
Below is the revised GetCurrentPrice method. It utilizes a new static class available to the entire application called SystemTime.Now. The method itself hasn’t really changed – we simply replaced a call from DateTime.Now to SystemTime.Now.
The easily testable method
public decimal GetCurrentPrice()
{
var priceHistory = PriceHistories
.Where(ph.EffectiveDate.Date <= SystemTime.Now.Date)
.OrderByDescending(ph => ph.EffectiveDate)
.Take(1)
.SingleOrDefault();
return (priceHistory != null) ? priceHistory.Price : 0m;
}
Now let’s write up our test case for our business rules. The SystemTime class provides a testability hook to override the notion of “Today” – using the internal SystemTime.SetCurrentTime field. Now we can deterministically wire up hard dates and guarantee that there will be no fluctuation in our tests. Consistency is absolutely key to successful long-term unit testing.
Our Fully Deterministic Test Case
[TestMethod]
public void GetCurrentPrice_WithMultiplePrices_ReturnsMostRecentPriceWhereTodayIsAfterEffectiveDate()
{
// Arrange
SystemTime.SetCurrentTime = () => new DateTime(2010, 3, 1);
var product = new Product();
product.PlanPriceHistories.Add(new PriceHistory
{
EffectiveDate = new DateTime(2010, 1, 1),
Price = 50000
});
product.PlanPriceHistories.Add(new PriceHistory
{
EffectiveDate = new DateTime(2011, 1, 1),
Price = 250000
});
// Act
var currentPrice = GetCurrentPrice();
// Assert
Assert.AreEqual(50000, currentPrice);
}
The SystemTime class can be seen below. The SetCurrentTime field is internal to prevent inadvertent changes to the current time. I use the [assembly:InternalsVisibleTo(“MyTestAssembly”)] attribute so that unit tests are able to override it.
The SystemTime Implementation
public static class SystemTime
{
// Allow modification of "Today" for unit testing
internal static Func<DateTime> SetCurrentTime = () => DateTime.Now;
public static DateTime Now
{
get
{
return SetCurrentTime();
}
}
}
Further Reading
Unit testing time has been covered by various people. I prefer the SystemTime method (which I modified slightly from Ayende). Other people prefer the IClock abstraction, which has been discussed quite a bit on StackOverflow.
Leave a Comment