Deceiving dates

Android GDE, craftsman and UI/UX maniac. He spends his time obsessing over details, ranting about things, finding coffee, cooking/eating/drinking great stuff, and generally being awesome.

JodaTime is a very powerful tool in a developer's toolbelt. At Novoda, we use it in almost all of our projects as it provides a set of date and time manipulation APIs that are vastly superior to the ones provided by Java itself. While it is more intuitive and it provides a lot of convenience over the standard Date APIs, it is not without caveats. In this post we’ll see how the wrong assumption can cause hard to catch bugs when it comes to comparing dates.

Time zones

CC BY-SA 2.0 by Archives New Zealand on flickr

Just a matter of time

While tweaking a small bit of code and the associated tests, a few of those tests turned red with a rather confusing message:

java.lang.AssertionError: expected:  
<'1970-01-02T11:17:36.789+01:00 (DateTime@26a52cca)'>  
 but was:
<'1970-01-02T11:17:36.789+01:00 (DateTime@621b503a)'>  

How is it possible that two seemingly identical DateTimes are considered different?

The issue here is masked by the representation. What JUnit uses to give you that String is DateTime.toString(), which happens to print out the date contents as an ISO-8601 formatted string. That representation is using the standard format:

yyyy-MM-dd'T'HH:mm:ss.SSSXXX

This representation contains all the basic information about the date (yyyy-MM-dd), the time (HH:mm:ss.SSS), and the timezone (XXX). So where is this difference, and what is masking it? Let's take a step back and look at how the DateTimes are constructed.

Every name tells a story

The two DateTimes we are comparing are constructed in a slightly different way:

DateTime firstDate = new DateTime(123456789L);  
DateTime secondDate = DateTime.parse("1970-01-02T11:17:36.789+01:00");  

The local timezone is, in case you were wondering, the London one. It's summer, so that means we are in BST (UTC+1). The DateTime constructor we use will get the system timezone, which will be Europe/London. If you format that to an ISO-8601 string, you indeed get "1970-01-02T11:17:36.789+01:00", the same as the string we parse to obtain the other date.

And yet, they are subtly different, even though their string representation is the same.

Why are they different? If you do firstDate.getZone() you get "Europe/London", whereas secondDate.getZone() will give you "+01:00". They indeed resolve to the same offset from UTC, one hour, but they are not the same! If you look at the types of the timezones, you'll notice the first difference: firstDate.getZone() is a CachedDateTimeZone, and secondDate.getZone() is a FixedDateTimeZone!

What does that mean? In simple terms, a FixedDateTimeZone is a timezone with an absolute, fixed offset from UTC. That means it is representing an offset that doesn't depend on things such as DST (and thus, the date in which you "resolve" its value). On the other hand, CachedDateTimeZone accounts for timezone offset changes throughout a year, such as those caused by DST. When you have a timezone that is tied to a geographic location, you use a CachedDateTimeZone because that location will have a different UTC offset depending on the instant the offset is resolved for.

This is made explicit by the DateTimeZone.isFixed() method, which will return true for a FixedDateTimeZone, and false for a CachedDateTimeZone.

Never mix and match!

The issue here is then caused by the fact that we mix and match a constructor, which uses the computer's timezone (we're running JUnit tests on the JVM), and a parse() call, which reads an absolute offset, such as "+01:00".

It's now clear what the issue is: a computer's timezone is usually bound in its settings to a location — in this case, Europe/London. An ISO-8601 string can only represent a fixed offset, in this case "+01:00".

As we've seen before, that means the timezones are different between firstDate and secondDate. Even though they both resolve to +01:00 today, because we're in BST, they will eventually diverge when the UK goes to the GMT (which is +00:00) timezone. Consequently, firstDate.getZone() and secondDate.getZone() are not equal.

The catch here is that Joda-Time, when comparing two DateTimes, compares their time zones too (contained in the Chronology, amongst other things). It is not simply comparing the Unix time millis as some might expect. Finally, we understand why firstDate and secondDate are not equal!

You can verify this behaviour yourself with a simple test case:

@Test
public void givenADateWithLocalTimeZone_andADateWithAFixedTimezone_whenCheckingEquality_thenTheyAreNotEqual() {  
    DateTime firstDate = new DateTime(123456789L);
    DateTime secondDate = DateTime.parse(firstDate.toString());

    assertThat(secondDate).isNotEqualTo(firstDate);
}

This is a problem, since a lot of code relies on equals, and it might behave differently than expected. If you are going to compare the values of two DateTimes, you have a few ways to make sure those comparisons don't fail:

As always, remember that dates are hard. Treat them with all the care, and get to know how they work, because if you don’t you’ll end up staring at a screen for hours trying to figure out some obscure bug. Murphy’s law is real, and dates are a great way to test it.

About Novoda

We plan, design, and develop the world’s most desirable Android products. Our team’s expertise helps brands like Sony, Motorola, Tesco, Channel4, BBC, and News Corp build fully customized Android devices or simply make their mobile experiences the best on the market. Since 2008, our full in-house teams work from London, Liverpool, Berlin, Barcelona, and NYC.

Let’s get in contact