How to Unit Test with Dates in AngularJS - ngMock Fundamentals
How to unit test Dates in AngularJS using ngMock and TzDate.
on
In this post we cover ngMock’s angular.mock.TzDate. It’s a wrapper for the native JavaScript Date type with an added API to make it easier to construct dates with time zone information. TzDate is not a complete replacement for Date, it just supplements it with a few useful features.
Recap of Dates in JavaScript
Before we dive into the features of TzDate, let’s quickly refresh our understanding of JavaScript dates, so that we can appreciate why they can be difficult to work with in unit tests, and why TzDate exists.
Time is measured in JavaScript in milliseconds since 01 January 1970. This means that creating a new date object new Date(0)
equates to Thu, 01 Jan 1970 00:00:00 GMT. The millisecond value can be any negative or positive number. Negative numbers will construct dates before 1970, and positive numbers will be dates starting from 1970.
We can also construct new dates via string representations, using a simplification of the ISO 8601 Extended Format. The format is YYYY-MM-DDTHH:mm:ss.sssZ
. We won’t examine every possible string combination, but be aware that we can create dates with or without time information.
What’s the Z suffix for? It’s used to denote that the time offset is zero hour. You may hear this called ‘Zero Hour’ or ‘Zulu Time’. We can specify timezone offsets by using + or - followed by a time expression in HH:mm in place of the Z e.g. YYYY-MM-DDTHH:mm:ss+HH:mm
.
Whenever we construct a new date, its base value will always be in UTC. UTC is an abbreviation for ‘Coordinated Universal Time’, which is a time standard by which the world regulates clocks and time. When we specify a date without specifying an offset, UTC is assumed. A Date object instance has two states, the UTC time, and a local time based on the different between the time zone offset of the system running executing the JavaScript and the UTC value.
Date Examples
To illustrate the concept, in the following code we will create a some dates and contrast the UTC and local time values. Each date object will be passed through the following debug function to print out some detail about the date in question:
var output = function(d) {
console.log('Local Date: ' + d.toLocaleDateString());
console.log('Local Hours: ' + d.getHours());
console.log('UTC Date: ' + d.toISOString());
console.log('UTC Hours: ' + d.getUTCHours());
}
For the following date: new Date(0)
, the output is as follows when run with system settings for CET (Central European Time):
Local Date: 1/1/1970
Local Hours: 1
UTC Date: 1970-01-01T00:00:00.000Z
UTC Hours: 0
If I change my system settings to be London GMT, the output is:
Local Date: 1/1/1970
Local Hours: 0
UTC Date: 1970-01-01T01:00:00.000Z
UTC Hours: 0
We can experiment with creating a date with a negative offset: new Date('1970-01-01T00:00:00-01:00')
Local Date: 1/1/1970
Local Hours: 2
UTC Date: 1970-01-01T01:00:00.000Z
UTC Hours: 1
The negative offset suffix -01:00
indicates that the date/time information provided is for a place that is one hour behind UTC, and that the time in that place is midnight. The result is that our date has a base UTC value of 01:00 am, and the local time is 2:00 am, since the local time settings of the machine running the code are in a place that’s one hour ahead of UTC.
We can experiment with creating a date with a positive offset: new Date('1970-01-01T00:00:00+01:00')
Local Date: 1/1/1970
Local Hours: 0
UTC Date: 1969-12-31T23:00:00.000Z
UTC Hours: 23
This is the inverse of the previous example. Here we give the date/time for a place that is one hour ahead of UTC. So midnight results in 23:00 hours in UTC. The local settings of the machine executing the JavaScript is one hour ahead of UTC, giving us a local time of midnight.
The key point of these examples is that the output from calling the functions toLocaleString and getHours etc changes based on the local settings. This makes sense, but can cause problems when it comes to verifying behaviour with dates when writing unit tests.
Why are Dates in JavaScript difficult to unit test?
Here’s a trivial application that will highlight some of the problems we face when unit testing with dates in JavaScript. The controller code has a basic condition to check if the $scope.nowTime value rolls into the next year, and set an appropriate message if so:
var app = angular.module('countdownApp', []);
app.controller('CountdownController', function countdownController($scope) {
if ($scope.nowTime.getFullYear() === $scope.nextYear) {
$scope.message = 'Happy new Year!';
} else {
$scope.message = 'Keep on counting down...!';
}
});
There are two simple unit tests for this controller for each state, once we’re into the new year and when we’re not.
The code is pretty simple, we create a date object with the date/time for each state that will control the behaviour of our controller code:
it('should display happy new year messsage', function () {
// yay, we're in the new year!
$scope.nowTime = new Date('2015-01-01:00:00:00Z');
$scope.nextYear = 2015;
var countdownController = $controller('CountdownController', { $scope: $scope });
expect($scope.message).toBe('Happy new Year!');
});
it('should display almost new year message', function () {
// we're one hour away from the new year
$scope.nowTime = new Date('2014-12-31:23:00:00Z');
$scope.nextYear = 2015;
var countdownController = $controller('CountdownController', { $scope: $scope });
expect($scope.message).toBe('Keep on counting down...!');
});
If you were based in London, these tests would both pass as expected. But what if you were to change your Date & Time settings to be somewhere else, like Paris? Would the tests still pass? When doing so, the second test: countdown tests should display almost new year message fails with the exception Expected 'Happy new Year!' to be 'Keep on counting down...!'.
How has the date 2014-12-31:23:00:00 turned into 2015?
When we call getFullYear in our controller, we’re getting the year value of the local time zone based on the UTC Date we setup in the test. In Paris, the UTC time 23:00 is 00:00 in local time, nudging us into the new year. This explains why these tests will pass in a London time zone, since the GMT time will be the same as our UTC Date, or +0 hours offset.
In terms of the logic, this is perfect and exactly what we want. But it’s not so good for unit tests since the system settings could potentially cause the tests to fail e.g. two developers working from different locations or running tests as part of a continuous integration process on a server in a different country.
TzDate to the Rescue
Using TzDate allows us to circumvent this problem by providing the ability to create Dates and also pass a time zone offset. Here are the same tests using angular.mock.TzDate to create the date objects. Given that we’ve provided a time zone offset in each test, the tests will always pass regardless of the system settings of the computer executing the tests:
it('should display happy new year messsage', function () {
$scope.nowTime = new angular.mock.TzDate(0, '2015-01-01T00:00:00Z');
$scope.nextYear = 2015;
var countdownController = $controller('CountdownController', { $scope: $scope });
expect($scope.message).toBe('Happy new Year!');
});
it('should display almost new year message', function () {
$scope.nowTime = new angular.mock.TzDate(0, '2014-12-31T23:00:00Z');
$scope.nextYear = 2015;
var countdownController = $controller('CountdownController', { $scope: $scope });
expect($scope.message).toBe('Keep on counting down...!');
});
How it works?
If we were to create the date: new angular.mock.TzDate(+1, '1970-01-01T00:00:00Z')
, the local system setting would be ignored and using the debug output as with the earlier examples, we would see:
Local Date: 12/31/1969
Local Hours: 23
UTC Date: 1970-01-01T00:00:00.000Z
UTC Hours: 0
By providing +1 for the offset we are saying: for the time I am providing, the UTC time is +1 hours ahead.
When a new angular.mock.TzDate is created, internally it creates two new Date objects called:
-
origDate - represents the original date, and will be the base UTC date. In the API, all the calls to getUTCMonth, getUTCHours etc call the respective methods on origDate.
-
date - represents the date that would normally be the local date, but will be calculated using the provided time zone offset. TzDate cleverly uses the offset provided, combined with the local systems offset to create a new date that matches the desired date.
In this example the local value of the date is Wed Dec 31 1969 23:00:00. We can understand the approach used in TzDate by inspecting properties of the UTC value of the internal date instance. Calling getUTCHours() on this function gives a value of 22 (or 10 pm). This is not correct based on the initial date value and offset we provided, which explains why a separate origDate object is used to represent UTC values.
So where did 22 come from? We gave a time of midnight, with an offset of +1 hour, which would be 23 hours for the local time. The system settings for my machine is CET, which is another hour ahead, which has been factored in the creation of the new date. A UTC time of 22, based on my local settings will return 23 when calling getHours which is what we expressed in the offset and date string we passed to TzDate.
Caveats
TzDate is not a complete implementation of Date object, which may result in runtime exceptions. If we were to call ~~~ $scope.nowTime.getUTCDay(); ~~~ in our controller code under test we would see the exception Uncaught Error: Method ‘getUTCDay’ is not implemented in the TzDate mock. To be fair, this is clearly stated in the documentation. Here is the full list of unimplemented methods:
['getUTCDay', 'getYear', 'setDate', 'setFullYear', 'setHours', 'setMilliseconds', 'setMinutes', 'setMonth', 'setSeconds', 'setTime', 'setUTCDate', 'setUTCFullYear', 'setUTCHours', 'setUTCMilliseconds', 'setUTCMinutes', 'setUTCMonth', 'setUTCSeconds', 'setYear', 'toDateString', 'toGMTString', 'toJSON', 'toLocaleFormat', 'toLocaleString', 'toLocaleTimeString', 'toSource', 'toString', 'toTimeString', 'toUTCString', 'valueOf'];
We can sometimes run into this problem when writing our angular logic and tests. Take the following trivial example, in this test we want to assert that the user given value in hours, is added to our base time:
it('should increment the time by the given hours', function () {
var addHours = function(d, h) {
d.setTime(d.getTime() + (h*60*60*1000));
}
var base = new angular.mock.TzDate(+1, '2014-12-31T23:00:00Z');
var useDefinedHours = 1;
addHours(base, useDefinedHours);
expect(base.getHours()).toEqual(23);
});
This test will of course fail as soon as we call setTime on our mock.TzDate instance since it’s not implemented. When encountering such issues, it’s useful to take a few steps back and consider what we are actually testing. Do we want to test that the Date object functionality works? Or just that we are passing the correct arguments?
For unit tests, we should be testing that we are using Date correctly as opposed to its functionality, therefore in many cases we can make use of Jasmine’s spy functionality and check that setTime was called with the argument we expect based on our state:
it('should increment the time by the given hours', function () {
var addHours = function(d, h) {
d.setTime(d.getTime() + (h*60*60*1000));
}
var base = new angular.mock.TzDate(+1, '2014-12-31T23:00:00Z');
spyOn(base, 'setTime');
var useDefinedHours = 1;
addHours(base, useDefinedHours);
expect(base.setTime).toHaveBeenCalledWith(1420070400000);
});
Example Test Code
Full code example of the tests used in this post via a Github Gist.