Skip navigation.

Temporal Anomalies in the DateTime Continuum

C# | test driven development | unit testing
[textile]Chris McMahon "writes an entertaining article":http://chrismcmahonsblog.blogspot.com/2005/12/but-it-works-on-my-machine.html about an interesting problem with a unit test. He explains the problem and identifies the appropriate solution. Despite this, there are still several interesting problems with the test. h2. Living in the Here and DateTime.Now! The test was a unit test for a Class called Range :
{ Range dateRange = new Range(DateTime.Now, DateTime.Now.AddDays(3)); Assert.IsFalse(dateRange.Includes(DateTime.Now.AddDays(-1)),"Yesterday should not be in the date range"); Assert.IsTrue(dateRange.Includes(DateTime.Now),"Today should be included in the date range"); Assert.IsTrue(dateRange.Includes(DateTime.Now.AddDays(3)),"The end of the range should exist in the date range"); Assert.IsFalse(dateRange.Includes(DateTime.Now.AddDays(4)),"4 days past the start date should not exist in the date range"); }
Chris highlights that the test failed intermittently on different environments. Due to the extensive use of DateTime.Now, the test presumes that it will always start and be completed on the same date and within a millisecond. Why? DateTime.Now returns the current date and time to the nearest millisecond. All it takes is for the 3rd assertion with DateTime.Now statement to be executed more than one millisecond later than the creation of the dateRange and the test would fail. h2. Frozen in Time Chris correctly identifies the solution. Assign DateTime.Now to a variable at the beginning of the test and reuse it. Despite Chris' concern that it is a "lot of work for a little unit test", this is actually requires minimal effort. The importance of a test shouldn't be measured by it's size or position in the testing stack, although I am sure there was more to this than the fact that it is a unit test. One extra line of code and a simple find and replace would do the trick... { %{padding:1cm}@DateTime testDate = DateTime.Now; // new line of code@% %{padding:1cm}@Range dateRange = new Range(testDate, testDate.AddDays(3));@% %{padding:1cm}@Assert.IsFalse(dateRange.Includes(testDate.AddDays(-1)),"Yesterday should not be in the date range");@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(testDate),"Today should be included in the date range");@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(testDate.AddDays(3)),"The end of the range should exist in the date range");@% %{padding:1cm}@Assert.IsFalse(dateRange.Includes(testDate.AddDays(4)),"4 days past the start date should not exist in the date range");@% } Chris states that the test was "scrapped as a unit test". If the Range class still exists, I'd say it might be worth spending a little time on the test to refine the design of the class. Remember, "TDD isn't (only) about testing":http://blog.alancfrancis.com/2005/10/tdd_is_so_about.html. There are still some fundamental bugs in the design. Let me explain... Firstly, let me say, I don't know enough about Chris' project to consider whether scrapping the test was the right choice. Based on the available information, however, I felt that his example presented some interesting test-design exercises that are worth exploring. h2. To the boundaries of DateTime Before we can see what the potential problems are, it is necessary to explore the DateTime class. In simple terms, DateTime.Now returns the current date and time. It is accurate to the nearest millisecond. The AddDays method adds days to the DateTime, for example, if testDate is 01/01/2006 10:00:00.000 then testDate.AddDays(3) returns 03/01/2006 10:00:00.000. If we then try: %{padding:1cm}@DateTime newDate = testDate.AddDays(3).AddSeconds(1);@% %{padding:1cm}@dateRange.Includes(newDate);@% newDate will be 03/01/2006 10:00:01 and the Includes() method may or may not return false. The test is ambiguous in this regard because the true boundary isn't tested. In reality, the true boundaries on the test-data can be described as follows: %{padding:1cm}@DateTime startDate = DateTime.Now;@% %{padding:1cm}@DateTime endDate = startDate.AddDays(3);@% %{padding:1cm}@DateTime lowerOuterBoundary = startDate.AddMilliseconds(-1);@% %{padding:1cm}@DateTime upperOuterBoundary = endDate.AddMilliseconds(1);@% h2. Date, Time or DateTime? Now the question is, what is required of Range.Includes()? What is it's rule boundary? Is it concerned with the time of day for the DateTime argument it receives or is it intended to check whether it's argument is on a given date within the range regardless of the time? Chris explains the purpose of the class as:
Range accepts two arguments, a begin date and an end date. From that it calculates a range of dates
Based on this, and the semantics of the test (in particular the variable dateRange), I'll assume that the Includes method isn't concerned with the time of day, but instead is concerned with whether its argument is within the range of dates, regardless of the time, and is inclusive of the start and end date. I am also assuming this for the purposes of this article because it is also a more interesting problem to solve. Either way, there is a big hole in the test just waiting to let a bug through to the design of the Range class! Let me elaborate on why... As already explained, DateTime.Now returns both the date and time. Thus, if startDate happened to be 01/01/2006 10:00:00.000 then the lower boundary value on this test date would be 01/01/2006 09:59:59.999. Should we assert true or false for: %{padding:1cm}@dateRange.Includes(lowerOuterBoundary); //see above@% As I have stated earlier, I'll assume that the Includes method is intended to determine if the date argument is between two dates (inclusive) but isn't concerned with the actual time of day. Thus, the assertion should be true: %{padding:1cm}@Assert.IsTrue(dateTime.Includes(lowerOuterBoundary),"same startDate, earlier time should still be considered as within the range");@% Let's not forget the endDate! Bear in mind that the boundary of the upper value is 23:59:59.999 on the end date. %{padding:1cm}@DateTime.Now.Date.AddDays(1).AddMilliseconds(-1); //23:59:59.999 on same day@% Let's think of two types of boundary value in this case. The test data boundary and the rule boundary. I'll show you what I mean: %{padding:1cm}@DateTime startDate = DateTime.Now.Date.AddHours(10); //results in 10:00:00 on current date@% %{padding:1cm}@DateTime endDate = startDate.AddDays(3); //results in 10:0:00.000 three days later@% %{padding:1cm}@DateTime lowerRuleInsideBoundary = startDate.Date; //first millisecond of day@% %{padding:1cm}@DateTime lowerRuleOutsideBoundary = startDate.Date.AddMilliseconds(-1); //one millisecond before the end of the previous day@% %{padding:1cm}@DateTime upperRuleInsideBoundary = endDate.Date.AddDays(1).AddMilliseconds(-1); //last millisecond of day@% %{padding:1cm}@DateTime upperRuleOutsideBoundary = endDate.Date.AddDays(1); //00:00:00.000 on the day after the end-date@% %{padding:1cm}@DateTime lowerRangeOutsideBoundary = startDate.AddMilliseconds(-1);@% %{padding:1cm}@DateTime upperRangeOutsideBoundary = endDate.AddMilliseconds(1);@% Notice that I use the DateTime.Date property. This returns the date and a time of 00:00:00.000, i.e. the start of the day. Using these values, we can now construct a more robust set of assertions: %{padding:1cm}@Range dateRange = new Range(startDate, endDate);@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(startDate)); //nothing new here@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(endDate)); // nothing new here@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(lowerRangeOutsideBoundary)); //sameDate one millisecond earlier@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(upperRangeOutsideBoundary); //sameDate one millisecond later@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(lowerRuleInsideBoundary)); first millisecond of the day@% %{padding:1cm}@Assert.IsTrue(dateRange.Includes(upperRuleInsideBoundary)); //last millisecond of last day@% %{padding:1cm}@Assert.IsFalse(dateRange.Includes(lowerRuleOutsideBoundary)); //previous date one millisecond before start date;@% %{padding:1cm}@Assert.IsFalse(dateRange.Includes(upperRuleOutsideBoundary)); //first millisecond of following day@% So, a relatively small amount of effort and a much more robust test! h2. So, what do we get for our money? The Range class' would probably start to look something like this: @public class Range@ { %{padding:1cm}@private DateTime _startDate;@% %{padding:1cm}@private DateTime _endDate;@% %{padding:1cm}@public Range(DateTime startDate,DateTime endDate)@% %{padding:1cm}{% %{padding:2cm}@_startDate = startDate.Date;@% %{padding:2cm}@_endDate = endDate.Date.AddDays(1).AddMilliseconds(-1);@% %{padding:1cm}}% %{padding:1cm}@// and the class continues... etc.@% } The constructor makes sure that, regardless of the time, the date boundaries are forced to be equal to the earliest possible time on the startDate and the latest possible time on the endDate. I would imagine that this would be adequate and for reasons of pragmatism, I'd probably not consider any further enhancements. Now, of course, Chris' project may require that the Range class deals with true DateTime rather than just Date, however, all the available information suggests that they are concerned with date ranges (rather than DateTime ranges). Based on this, I'd give serious consideration to the semantics of the Range class. I'd probably prefer to call it DateRange rather than just 'Range'. Chris' case study is a valuable learning experience and I hope members of his project learn from it by reviewing his article. Hopefully, they may also find this article of value too. Keep up the good work Chris and keep on blogging! Antony Marcano ---- P.S. One way to further capitalise on the learning experience and maximise the quality of their tests could be to pair developers and testers. "Pair Programming":http://www.pairprogramming.com/ is most often described as partnering two developers, however, in my experience as a tester, there is also value in adopting the same approach but by pairing the developer with a tester.