The purpose of this article is to help you understand the basic concepts of Test-Driven Development (TDD) in JavaScript.
We’ll begin by walking through the development of a small project in a test-driven way. The first part will focus on unit tests, and the last part on code coverage. If you want to experiment with the project on your own, there is a repository hosted on GitHub that puts together all the code from this article.
In a nutshell, TDD changes our regular workflow. Traditionally, the software development workflow is mostly a loop of the following steps:
For example, let's say we want to write a function add(number1, number2)
to add two numbers together. First, we write the add
function, then we try a few examples to see if it gives the output we expect. We could try running add(1,1)
, add(5,7)
and add(-4, 5)
, and we might get the outputs 2
, 12
, and... oops, there must be a bug somewhere, -9
.
We revise the code to try to fix the incorrect output, and then we run add(-4, 5)
again. Maybe it will return 1
, just like we wanted. Just to be safe, we'll run the other examples to see if they still give the right output. Oops, now add(5,7)
returns -2
. We'll go through a few cycles of this: we revise our code and then try a few examples until we're sufficiently confident that our code works just the way we want.
Test-driven development changes this workflow by writing automated tests, and by writing tests before we write the code. Here’s our new workflow:
So, before we even start writing our add
function, we'll write some test code that specifies what output we expect. This is called unit testing. Unit tests might look something like this:
1expect(add(1, 1)).toEqual(2);
2expect(add(5, 7)).toEqual(12);
3expect(add(-4, 5)).toEqual(1);
We can do this for as many test examples as we like. Then, we’ll write the add
function. When we're finished, we can run the test code and it will tell us whether our function passes all the tests:
2 of 3 tests passed. 1 test failed:
Expectedadd(-4,5)
to return1
, but got-9
.
OK, so we failed 1 of the tests. We'll revise the code to try to correct the mistake, and then we'll run the tests again:
1 of 3 tests passed. 2 tests failed:
Expectedadd(1,1)
to return2
, but got0
.
Expectedadd(5,7)
to return12
, but got-2
.
Oops, we fixed one thing but broke other things at the same time. We'll revise the code again and see if there are still any problems:
3 of 3 tests passed. Yay!
Of course, just because our code passed the tests it doesn't mean the code works in general. But it does give us a little more confidence about its correctness. And if we find a bug in the future that our tests missed, we can always add more tests for better coverage.
Many of you might object, "But what's the point of that? Isn't that just a lot of pointless extra bother?"
It’s true that setting up the testing environment and figuring out how to unit write tests often takes some effort. In the short run, it's faster to just do things the traditional way. But in the long run, TDD can save time that would otherwise be wasted manually testing the same thing repeatedly. And it just so happens that there are a number of other benefits to unit testing:
Sometimes you'll write a bug in your program that causes code that used to function properly to no longer do so, or you'll accidentally reintroduce an old bug that you previously fixed. This is called a regression. Regressions might sneak by unnoticed for a long time if you don't have any automated testing. Passing your unit tests doesn’t guarantee that your code works correctly, but if you write tests for every bug you fix, one thing passing your unit tests can guarantee is that you haven't reintroduced an old bug.
Code can get messy pretty quickly, but it's often scary to refactor it since there's a good chance that you'll break something in the process. After all, code often looks messy because you had to hack together some workarounds to make it work for rare edge cases. When you try to clean it up, or even rewrite it from scratch, it's likely that it will fail on those edge cases. If you have unit tests covering these edge cases, you'll find out immediately when you've broken something and you can make changes more courageously.
If another developer (or perhaps the future you) can't figure out how to use the code you've written, they can look at the unit tests to see how the code was designed to be used. Unit tests aren't a replacement for real documentation, of course, but they're certainly better than no documentation at all (which is all too common, since programmers almost always have things higher on their priority lists than writing documentation).
When you have no automated testing and applications become sufficiently complex, it’s easy for the code to feel very fragile. That is, it seems to work fine (most of the time) when you use it, but you have a nagging anxiety that the slightest unexpected action from the user or the slightest future modification to the code will cause everything to crash and burn. Knowing that your code passes a suite of unit tests is more reassuring than knowing that your code seemed to work when you manually tested it with a handful of examples the other day.
OK, enough with the theory, let's get our hands dirty and see how this works in practice.
In this example, we’ll go through the process of developing a simple date library in a test-driven way. For each part of the library, we’ll first write tests specifying how we want it to behave, and then write code to implement that behavior. If the test fails, we know that the implementation does not match the specification. Keep in mind that the purpose of this code is only to demonstrate test-driven development, and is not a feature-complete date library meant for practical use. If you somehow stumbled upon this article looking for a date library, I recommend Moment.js.
Here's how I set things up:
test
.test
folder.SpecRunner.html
file, you should see something like this:
DateTime.js
, and in the test/spec
folder create a file named DateTimeSpec.js
. Delete the other files in the spec
folder; we don't need them anymore.SpecRunner.html
file to reference our scripts. It should look something like this:1<script src="lib/jasmine-2.4.1/jasmine.js"></script>
2<script src="lib/jasmine-2.4.1/jasmine-html.js"></script>
3<script src="lib/jasmine-2.4.1/boot.js"></script>
4
5<!-- include source files here... -->
6<script src="../DateTime.js" data-cover></script>
7
8<!-- include spec files here... -->
9<script src="spec/DateTimeSpec.js"></script>
If you refresh SpecRunner.html
now, it should say "No specs found" since we haven't written anything yet. With that out of the way, now we can start building our library.
Feel free to quickly skim through this section to just get a basic idea of what our date library will do. There's no need to remember every single detail in here; you can always refer back to this section if you’re confused about the intended behavior of the code.
DateTime
is a function that constructs dates in one of the following ways:
DateTime()
, called with no arguments, creates an object representing the current date/time.
1var d = DateTime(); // d represents the current date/time.
DateTime(date)
, called with one argument date
, a native JavaScript Date
object, creates an object representing the date/time corresponding to date
.
1var d = DateTime(new Date(0)); // d represents 1 Jan 1970 00:00:00 GMT
DateTime(dateString, formatString)
, called with two arguments dateString
and formatString
, returns an object representing the date/time encoded in dateString
, which is interpreted using the format specified in formatString
.
1var d = DateTime("1/5/2012", "D/M/YYYY");
The object returned by DateTime
will have the following method
toString(formatString?)
- returns a string representation of the date, using the optional formatString
argument to specify how the output should be formatted. If no formatString
is provided, it will default to "YYYY-M-D H:m:s"
.
1> DateTime().toString()
2"2016-2-22 15:06:42"
3> DateTime().toString("MMMM Do, YYYY")
4"February 22nd, 2016"
And the following properties:
year
- the full (usually 4-digit) year (e.g. 1997). Represented in a format string as YYYY
. Readable/writable.monthName
- the name of the month (e.g. December). Represented in a format string as MMMM
. Readable/writable.month
- the number of the month (e.g. 12
for December). Represented in a format string as M
. Readable/writable.day
- the name of the weekday (e.g. Tuesday). Represented in a format string as dddd
. Readonly.date
- the date of the month (e.g. 22). Represented in a format string as D
. Readable/writable.ordinalDate
- the ordinal date of the month (e.g. 22nd). Represented in a format string as Do
. Readable/writable.hours
- a number from 0 to 23, corresponding to the hour. Represented in a format string as H
. Readable/writable.hours12
- a number from 1 to 12, corresponding to the hour in the 12-hour system. Represented in a format string as h
. Readable/writable.minutes
- a number from 0 to 59, corresponding to the minute. Represented in a format string as m
. Readable/writable.seconds
- a number from 0 to 59, corresponding to the minute. Represented in a format string as s
. Readable/writable.ampm
- a string, either "am"
or "pm"
. Represented in a format string as a
. Readable/writable.offset
- returns the Unix offset, that is, the number of milliseconds since January 1st, 1970, 00:00:00. Readable/writable.The first thing we need to do is decide on some minimal subset of the API to implement, and then incrementally build on top of that until we're finished. One reasonable place to start is making a DateTime
constructor that returns an object representing the current time. With this in mind, we’ll write a unit test that specifies what we expect DateTime
to do. In DateTimeSpec.js
, we'll write our first test. If you don't understand what this test code is doing yet, don't worry; I'll explain it shortly.
1describe("DateTime", function() {
2 it("returns the current time when called with no arguments", function() {
3 var lowerLimit = new Date().getTime(),
4 offset = DateTime().offset,
5 upperLimit = new Date().getTime();
6 expect(offset).not.toBeLessThan(lowerLimit);
7 expect(offset).not.toBeGreaterThan(upperLimit);
8 });
9});
Now open SpecRunner.html
and click on "Spec List". You should see 1 failing test:
If you click "Failures" you will see some message about a ReferenceError because DateTime is not defined. That is exactly what should happen, since we haven't written any code defining DateTime
yet.
If the test code above didn't make sense to you, here’s a brief explanation of the Jasmine functions. You can read more details from the Jasmine docs.
describe("DateTime", ...)
- this creates a test group (or "suite") named "DateTime". We will put any tests specifying the behavior of DateTime
inside of this test suite.it(testName, ...)
- this creates a test (or "spec") with the given name. As a general rule, the spec name should read like the predicate of a sentence with the name of the test group as an implied subject (in our case, DateTime
). For this reason, the function is named it
to encourage you to read the spec name like a sentence starting with the word "it".expect
- this specifies what the spec expects to happen. If all the expectations in a spec are met, then the spec passes. If at least one expectation is not met, or an unhandled exception is thrown, then the spec fails.
In this case, we want to test whether DateTime()
actually returns the current time. In the test code above, I recorded the time before and after we called DateTime()
, and then tested if the time returned by DateTime
was between these two limits.Now that we've finished writing our first test, we can write code to implement the features we’re testing. In the DateTime.js
file, paste the following code:
1"use strict";
2var DateTime = (function() {
3 function createDateTime(date) {
4 return {
5 get offset() {
6 return date.getTime();
7 }
8 };
9 }
10
11 return function() {
12 return createDateTime(new Date());
13 };
14})();
When we open SpecRunner.html
our test should pass:
Great, now we've completed our first development iteration.
A reasonable next step is to implement the DateTime(date)
constructor. First, we write a unit test for it:
1it("matches the passed in Date when called with one argument", function() {
2 var dates = [new Date(), new Date(0), new Date(864e13), new Date(-864e13)];
3 for (var i = 0; i < dates.length; i++) {
4 expect(DateTime(dates[i]).offset).toEqual(dates[i].getTime());
5 }
6});
I've chosen four dates to test: the current date, and three dates that are potential edge cases:
Testing all of these may seem a little superfluous, since we're just writing a wrapper around the native Date
object and there's not any complicated logic going on. That said, as a general rule, it's a good idea to use potential edge cases in your tests to increase the chances of finding bugs sooner. There are still two important issues we haven't specified anything about yet in our tests:
DateTime
that is not a Date
object?Date
object that is invalid, such as new Date(1e99)
or new Date("Invalid string")
?There are lots of possible answers to these two questions depending on your error handling philosophy, and discussing such philosophical quandaries is outside the scope of this article. I chose the following strategies for dealing with these two issues:
Date
methods we use will return NaN
in this situation, so our DateTime
properties and methods will propagate this behavior automatically.Don't think too hard about the reasoning behind these choices, because there isn't all that much. I made these choices mostly so that I could demonstrate how to write tests that expect errors to be thrown and tests that expect NaN
. In any case, regardless of what behavior we might decide on, we should write tests that codify that behavior--so here we go:
1it("throws an error when called with a single non-Date argument", function() {
2 var nonDates = [0, NaN, Infinity, "", "not a date", null, /regex/, {}, []];
3 for (var i = 0; i < nonDates.length; i++) {
4 expect(DateTime.bind(null, nonDates[i])).toThrow();
5 }
6});
7it("returns a NaN offset when an invalid date is passed in", function() {
8 var invalidDates = [new Date(864e13 + 1), new Date(-1e99), new Date("xyz")];
9 for (var i = 0; i < invalidDates.length; i++) {
10 expect(isNaN(DateTime(invalidDates[i]).offset)).toBe(true);
11 }
12});
This is fairly straightforward, except for the DateTime.bind
part. The function binding is used because (1) .toThrow()
assumes a function was passed to expect
, and (2) creating a function inside of a loop in the straightforward way behaves somewhat counter-intuitively in JavaScript.
When we open SpecRunner.html
now we should see that the three specs we just wrote all failed. This is an important thing to check: If a spec passes before we write the implementation code, that usually means we made a mistake while writing the spec.
Now we can write the implementation:
1// ...
2return function(date) {
3 if (date !== undefined) {
4 if (date instanceof Date) {
5 return createDateTime(date);
6 }
7 throw new Error(String(date) + " is not a Date object.");
8 }
9 return createDateTime(new Date());
10};
All the tests should pass.
The easiest next step is to implement all the property getters. Writing tests for all of them is straightforward, although a little tedious. All that's involved is looping through some test dates and making sure that all the property getters return the expected values for these test dates.
When choosing test dates it's a good idea to include both typical dates as well as some potential edge cases.
1var testDates = [
2 "0001-01-01T00:00:00", // Monday
3 "0021-02-03T07:06:07", // Wednesday
4 "0321-03-06T14:12:14", // Sunday
5 "1776-04-09T21:18:21", // Tuesday
6 "1900-05-12T04:24:28", // Saturday
7 "1901-06-15T11:30:35", // Saturday
8 "1970-07-18T18:36:42", // Saturday
9 "2000-08-21T01:42:49", // Monday
10 "2008-09-24T08:48:56", // Wednesday
11 "2016-10-27T15:54:03", // Thursday
12 "2111-11-30T22:01:10", // Monday
13 "9999-12-31T12:07:17", // Friday
14 864e13, // max date (Sat 13 Sep 275760 00:00:00)
15 -864e13, // min date (Tue 20 Apr -271821 00:00:00)
16 -623e11 // single-digit negative year (Tue 17 Oct -5 04:26:40)
17].map(function(x) {
18 return DateTime(new Date(x));
19});
20
21var expectedValues = {
22 year: [
23 1,
24 21,
25 321,
26 1776,
27 1900,
28 1901,
29 1970,
30 2000,
31 2008,
32 2016,
33 2111,
34 9999,
35 275760,
36 -271821,
37 -5
38 ],
39 monthName: [
40 "January",
41 "February",
42 "March",
43 "April",
44 "May",
45 "June",
46 "July",
47 "August",
48 "September",
49 "October",
50 "November",
51 "December",
52 "September",
53 "April",
54 "October"
55 ],
56 month: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 9, 4, 10],
57 day: [
58 "Monday",
59 "Wednesday",
60 "Sunday",
61 "Tuesday",
62 "Saturday",
63 "Saturday",
64 "Saturday",
65 "Monday",
66 "Wednesday",
67 "Thursday",
68 "Monday",
69 "Friday",
70 "Saturday",
71 "Tuesday",
72 "Tuesday"
73 ],
74 date: [1, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 31, 13, 20, 17],
75 ordinalDate: [
76 "1st",
77 "3rd",
78 "6th",
79 "9th",
80 "12th",
81 "15th",
82 "18th",
83 "21st",
84 "24th",
85 "27th",
86 "30th",
87 "31st",
88 "13th",
89 "20th",
90 "17th"
91 ],
92 hours: [0, 7, 14, 21, 4, 11, 18, 1, 8, 15, 22, 12, 0, 0, 4],
93 hours12: [12, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 12, 12, 12, 4],
94 minutes: [0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 1, 7, 0, 0, 26],
95 seconds: [0, 7, 14, 21, 28, 35, 42, 49, 56, 3, 10, 17, 0, 0, 40],
96 ampm: [
97 "am",
98 "am",
99 "pm",
100 "pm",
101 "am",
102 "am",
103 "pm",
104 "am",
105 "am",
106 "pm",
107 "pm",
108 "pm",
109 "am",
110 "am",
111 "am"
112 ],
113 offset: [
114 -62135596800000,
115 -61501568033000,
116 -52031843266000,
117 -6113414499000,
118 -2197654532000,
119 -2163155365000,
120 17174202000,
121 966822169000,
122 1222246136000,
123 1477583643000,
124 4478364070000,
125 253402258037000,
126 8640000000000000,
127 -8640000000000000,
128 -62300000000000
129 ]
130};
131
132describe("getter", function() {
133 Object.keys(expectedValues).forEach(function(propertyName) {
134 it(
135 "returns expected values for property '" + propertyName + "'",
136 function() {
137 testDates.forEach(function(testDate, i) {
138 expect(testDate[propertyName]).toEqual(
139 expectedValues[propertyName][i]
140 );
141 });
142 }
143 );
144 });
145});
All the new tests we just wrote should fail now, except the one corresponding to the offset
property, since we already implemented the getter for offset
.
Now that we've written the tests we can write the implementation code.
1var monthNames = [
2 "January",
3 "February",
4 "March",
5 "April",
6 "May",
7 "June",
8 "July",
9 "August",
10 "September",
11 "October",
12 "November",
13 "December"
14 ],
15 dayNames = [
16 "Sunday",
17 "Monday",
18 "Tuesday",
19 "Wednesday",
20 "Thursday",
21 "Friday",
22 "Saturday"
23 ];
24
25function createDateTime(date) {
26 return {
27 get year() {
28 return date.getFullYear();
29 },
30 get monthName() {
31 return monthNames[date.getMonth()];
32 },
33 get month() {
34 return date.getMonth() + 1;
35 },
36 get day() {
37 return dayNames[date.getDay()];
38 },
39 get date() {
40 return date.getDate();
41 },
42 get ordinalDate() {
43 var n = this.date;
44 var suffix = "th";
45 if (n < 4 || n > 20) {
46 suffix = ["st", "nd", "rd"][n % 10 - 1] || suffix;
47 }
48 return n + suffix;
49 },
50 get hours() {
51 return date.getHours();
52 },
53 get hours12() {
54 return this.hours % 12 || 12;
55 },
56 get minutes() {
57 return date.getMinutes();
58 },
59 get seconds() {
60 return date.getSeconds();
61 },
62 get ampm() {
63 return this.hours < 12 ? "am" : "pm";
64 },
65 get offset() {
66 return date.getTime();
67 }
68 };
69}
Here’s a fun fact: I made quite a few mistakes in the process of writing the code above that the tests helped me catch. Most of them were fairly trivial and uninteresting mistakes that I would probably have eventually found anyway, but there is one subtler bug that I have left in the code above that I want to show you now.
When I run the tests I see eight failed specs. This number might vary depending on your time zone. Here’s one of my failed expectations:
DateTime getter returns expected values for property 'monthName'
Expected 'December' to equal 'November'.
This is caused by the 2111-11-30T22:01:10
date. My monthName
getter says this is December instead of November. This is because I live in the GMT+8 timezone, so something behind the scenes is converting the time from GMT into my timezone, resulting in 2111-12-01 06:01:10
. The solution to this problem is to use the getUTCMonth
method instead of the getMonth
method to prevent this conversion:
1get monthName() {
2 return monthNames[date.getUTCMonth()];
3},
The same logic applies to the other methods, like getFullYear
/getUTCFullYear
, getDay
/getUTCDay
, and so forth:
1return {
2 get year() {
3 return date.getUTCFullYear();
4 },
5 get monthName() {
6 return monthNames[date.getUTCMonth()];
7 },
8 get month() {
9 return date.getUTCMonth() + 1;
10 },
11 get day() {
12 return dayNames[date.getUTCDay()];
13 },
14 get date() {
15 return date.getUTCDate();
16 },
17 get ordinalDate() {
18 var n = this.date;
19 var suffix = "th";
20 if (n < 4 || n > 20) {
21 suffix = ["st", "nd", "rd"][n % 10 - 1] || suffix;
22 }
23 return n + suffix;
24 },
25 get hours() {
26 return date.getUTCHours();
27 },
28 get hours12() {
29 return this.hours % 12 || 12;
30 },
31 get minutes() {
32 return date.getUTCMinutes();
33 },
34 get seconds() {
35 return date.getUTCSeconds();
36 },
37 get ampm() {
38 return this.hours < 12 ? "am" : "pm";
39 },
40 get offset() {
41 return date.getTime();
42 }
43};
After these modifications, all the tests should now pass. This type of bug is a bit nasty because the unit tests might not catch it if your timezone is close to GMT, and I'm not sure I would have even noticed it if I hadn't written the unit tests.
Now that we've implemented all the getters, the obvious next step is to implement all the setters. Fortunately, to write tests for the setters, we don't need to create any more test dates or expected values, we can just reverse the process we used for the getter tests. Before, we had some date, such as2008-09-24T08:48:56
, and we were checking that the year property returned 2008
, the month property returned 9
, and so on.
This time, we’ll create a date, set it's year property to 2008
, set it's month property to 9
, and so forth, and check that it's offset is the same as the offset for 2008-09-24T08:48:56
. We have some overlapping properties, like month
and monthName
that set the same information, and offset
which affects everything else, so we will do three passes to test all of the properties:
year
, month
, date
, hours
, minutes
, and seconds
properties.year
, monthName
, ordinalDate
, ampm
, hours12
, minutes
, and seconds
properties.offset
property.Here's the code for the setter unit tests:
1describe("setter", function() {
2 var settableProperties = [
3 ["seconds", "minutes", "hours", "date", "month", "year"],
4 [
5 "seconds",
6 "minutes",
7 "hours12",
8 "ampm",
9 "ordinalDate",
10 "monthName",
11 "year"
12 ],
13 ["offset"]
14 ];
15 it("can reconstruct a date using the property setters", function() {
16 testDates.forEach(function(date, i) {
17 settableProperties.forEach(function(properties) {
18 var date = DateTime(new Date(0));
19 properties.forEach(function(property) {
20 date[property] = expectedValues[property][i];
21 });
22 expect(date.offset).toEqual(expectedValues.offset[i]);
23 });
24 });
25 });
26});
As usual, when you run these tests, they should all fail since we haven't written the setter code yet.
Finally, the only property left is the day
property, which is read-only. In JavaScript, writing to read-only properties fails silently by default:
1> var obj = { get readonlyProperty() { return 1; } };
2> obj.readonlyProperty
31
4> obj.readonlyProperty = 2;
5> obj.readonlyProperty
61
In my opinion, this is bad design: There's no warning when you try to write to a property without a setter and you might waste time later trying to figure out why it didn't work. Instead, we should throw an error when an attempt is made to write to day
. Let's specify that with a test:
1it("throws an error on attempt to write to property 'day'", function() {
2 expect(function() {
3 var date = DateTime();
4 date.day = 4;
5 }).toThrow();
6});
Again, this test should fail since we haven't written the implementation yet.
Here’s the code for the setters:
1set year(v) {
2 date.setUTCFullYear(v);
3},
4set month(v) {
5 date.setUTCMonth(v - 1);
6},
7set monthName(v) {
8 var index = monthNames.indexOf(v);
9 if (index < 0) {
10 throw new Error("'" + v + "' is not a valid month name.");
11 }
12 date.setUTCMonth(index);
13},
14set day(v) {
15 throw new Error("The property 'day' is readonly.");
16},
17set date(v) {
18 date.setUTCDate(v);
19},
20set ordinalDate(v) {
21 date.setUTCDate(+v.slice(0, -2));
22},
23set hours(v) {
24 date.setUTCHours(v);
25},
26set hours12(v) {
27 date.setUTCHours(v % 12);
28},
29set minutes(v) {
30 date.setUTCMinutes(v);
31},
32set seconds(v) {
33 date.setUTCSeconds(v);
34},
35set ampm(v) {
36 if (!/^(am|pm)$/.test(v)) {
37 throw new Error("'" + v + "' is not 'am' or 'pm'.");
38 }
39 if (v !== this.ampm) {
40 date.setUTCHours((this.hours + 12) % 24);
41 }
42},
43set offset(v) {
44 date.setTime(v);
45}
All the tests should pass now.
The only things left now are the DateTime(dateString, formatString)
constructor and the toString(formatString?)
method. The two of these are related, since they both involve a format string, so I'm including both of them in this section. We can reuse the same test dates from before, but we need to specify what strings we expect from them given different formats:
1var expectedStrings = {
2 "YYYY-M-D H:m:s": [
3 "1-1-1 0:00:00",
4 "21-2-3 7:06:07",
5 "321-3-6 14:12:14",
6 "1776-4-9 21:18:21",
7 "1900-5-12 4:24:28",
8 "1901-6-15 11:30:35",
9 "1970-7-18 18:36:42",
10 "2000-8-21 1:42:49",
11 "2008-9-24 8:48:56",
12 "2016-10-27 15:54:03",
13 "2111-11-30 22:01:10",
14 "9999-12-31 12:07:17",
15 "275760-9-13 0:00:00",
16 "-271821-4-20 0:00:00",
17 "-5-10-17 4:26:40"
18 ],
19 "dddd, MMMM Do YYYY h:m:s a": [
20 "Monday, January 1st 1 12:00:00 am",
21 "Wednesday, February 3rd 21 7:06:07 am",
22 "Sunday, March 6th 321 2:12:14 pm",
23 "Tuesday, April 9th 1776 9:18:21 pm",
24 "Saturday, May 12th 1900 4:24:28 am",
25 "Saturday, June 15th 1901 11:30:35 am",
26 "Saturday, July 18th 1970 6:36:42 pm",
27 "Monday, August 21st 2000 1:42:49 am",
28 "Wednesday, September 24th 2008 8:48:56 am",
29 "Thursday, October 27th 2016 3:54:03 pm",
30 "Monday, November 30th 2111 10:01:10 pm",
31 "Friday, December 31st 9999 12:07:17 pm",
32 "Saturday, September 13th 275760 12:00:00 am",
33 "Tuesday, April 20th -271821 12:00:00 am",
34 "Tuesday, October 17th -5 4:26:40 am"
35 ],
36 "YYYY.MMMM.M.dddd.D.Do.H.h.m.s.a": [
37 "1.January.1.Monday.1.1st.0.12.00.00.am",
38 "21.February.2.Wednesday.3.3rd.7.7.06.07.am",
39 "321.March.3.Sunday.6.6th.14.2.12.14.pm",
40 "1776.April.4.Tuesday.9.9th.21.9.18.21.pm",
41 "1900.May.5.Saturday.12.12th.4.4.24.28.am",
42 "1901.June.6.Saturday.15.15th.11.11.30.35.am",
43 "1970.July.7.Saturday.18.18th.18.6.36.42.pm",
44 "2000.August.8.Monday.21.21st.1.1.42.49.am",
45 "2008.September.9.Wednesday.24.24th.8.8.48.56.am",
46 "2016.October.10.Thursday.27.27th.15.3.54.03.pm",
47 "2111.November.11.Monday.30.30th.22.10.01.10.pm",
48 "9999.December.12.Friday.31.31st.12.12.07.17.pm",
49 "275760.September.9.Saturday.13.13th.0.12.00.00.am",
50 "-271821.April.4.Tuesday.20.20th.0.12.00.00.am",
51 "-5.October.10.Tuesday.17.17th.4.4.26.40.am"
52 ]
53};
Once we've constructed this object, it's straightforward to write the tests:
toString
, we go through all the test dates (e.g. 1970-07-18T18:36:42
) and see if they return the expected string for each of the formats (e.g. "1970-7-18 18:36:42"
for "YYYY-M-D H:m:s"
).DateTime(dateString, formatString)
constructor, we go through all the pairs of formats and date strings (e.g. "1970-7-18 18:36:42"
and "YYYY-M-D H:m:s"
) and make sure the constructed object has the same offset as the corresponding test date.That’s expressed in code here:
1describe("toString", function() {
2 it("returns expected values", function() {
3 testDates.forEach(function(date, i) {
4 for (format in expectedStrings) {
5 expect(date.toString(format)).toEqual(expectedStrings[format][i]);
6 }
7 });
8 });
9});
10
11it("parses a string as a date when passed in a string and a format string", function() {
12 for (format in expectedStrings) {
13 expectedStrings[format].forEach(function(date, i) {
14 expect(DateTime(date, format).offset).toEqual(testDates[i].offset);
15 });
16 }
17});
As usual, these tests should fail if we run them now.
Here's the implementation code to add these features. This is the most complicated part of the library, so the code here is not as simple as the code we've written up to this point. Feel free to just skim through this code to get the big picture without analyzing the finer details.
1"use strict";
2var DateTime = (function() {
3 var monthNames = [
4 "January",
5 "February",
6 "March",
7 "April",
8 "May",
9 "June",
10 "July",
11 "August",
12 "September",
13 "October",
14 "November",
15 "December"
16 ],
17 dayNames = [
18 "Sunday",
19 "Monday",
20 "Tuesday",
21 "Wednesday",
22 "Thursday",
23 "Friday",
24 "Saturday"
25 ];
26
27 function createDateTime(date) {
28 return {
29 // ... skipping the getters/setters to save space
30 toString: function(formatString) {
31 formatString = formatString || "YYYY-M-D H:m:s";
32 return toString(this, formatString);
33 }
34 };
35 }
36
37 var formatAbbreviations = {
38 YYYY: "year",
39 YY: "shortYear",
40 MMMM: "monthName",
41 M: "month",
42 dddd: "day",
43 D: "date",
44 Do: "ordinalDate",
45 H: "hours",
46 h: "hours12",
47 m: "minutes",
48 s: "seconds",
49 a: "ampm"
50 };
51 var maxAbbreviationLength = 4;
52 var formatPatterns = {
53 year: /^-?[0-9]+/,
54 monthName: /January|February|March|April|May|June|July|August|September|October|November|December/,
55 month: /[1-9][0-9]?/,
56 day: /Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday/,
57 date: /[1-9][0-9]?/,
58 ordinalDate: /[1-9][0-9]?(st|nd|rd|th)/,
59 hours: /[0-9]{1,2}/,
60 hours12: /[0-9]{1,2}/,
61 minutes: /[0-9]{1,2}/,
62 seconds: /[0-9]{1,2}/,
63 ampm: /am|pm/
64 };
65
66 function tokenize(formatString) {
67 var tokens = [];
68 for (var i = 0; i < formatString.length; i++) {
69 var slice, propertyName;
70 for (var j = maxAbbreviationLength; j > 0; j--) {
71 slice = formatString.slice(i, i + j);
72 propertyName = formatAbbreviations[slice];
73 if (propertyName) {
74 tokens.push({ type: "property", value: propertyName });
75 i += j - 1;
76 break;
77 }
78 }
79 if (!propertyName) {
80 tokens.push({ type: "literal", value: slice });
81 }
82 }
83 return tokens;
84 }
85
86 function toString(dateTime, formatString) {
87 var tokens = tokenize(formatString);
88 return tokens
89 .map(function(token) {
90 if (token.type === "property") {
91 var value = dateTime[token.value];
92 if (token.value === "minutes" || token.value === "seconds") {
93 value = ("00" + value).slice(-2);
94 }
95 return value;
96 }
97 return token.value;
98 })
99 .join("");
100 }
101
102 function parse(string, formatString) {
103 var tokens = tokenize(formatString);
104 var properties = {};
105 tokens.forEach(function(token, i) {
106 if (token.type === "literal") {
107 var value = token.value,
108 slice = string.slice(0, value.length);
109 if (slice !== value) {
110 throw new Error(
111 "String does not match format. Expected '" +
112 slice +
113 "' to equal '" +
114 value +
115 "'."
116 );
117 }
118 string = string.slice(value.length);
119 } else {
120 var format = token.value,
121 pattern = formatPatterns[format],
122 match = string.match(pattern);
123 if (!match || !match.length) {
124 throw new Error(
125 "String does not match format. Expected '" +
126 string +
127 "' to start with the pattern " +
128 pattern +
129 "."
130 );
131 }
132 match = match[0];
133 string = string.slice(match.length);
134 properties[format] = match;
135 }
136 });
137 var propertyOrder = [
138 "seconds",
139 "minutes",
140 "hours12",
141 "ampm",
142 "hours",
143 "ordinalDate",
144 "date",
145 "monthName",
146 "month",
147 "year"
148 ];
149 var date = createDateTime(new Date(0));
150 propertyOrder.forEach(function(property) {
151 if (properties[property]) {
152 date[property] = properties[property];
153 }
154 });
155 return date;
156 }
157
158 return function(date, formatString) {
159 if (date !== undefined) {
160 if (date instanceof Date) {
161 return createDateTime(date);
162 }
163 if (typeof formatString === "string") {
164 return parse(date, formatString);
165 }
166 throw new Error(String(date) + " is not a Date object.");
167 }
168 return createDateTime(new Date());
169 };
170})();
Now all tests should pass. The process of writing this part was where the unit tests became the most useful. Since the amount and complexity of the code here is relatively greater here, there were lots of bugs that I encountered while writing this that the tests helped me spot quickly. I haven't detailed all iterations of mistakes and fixes that I went through writing this, since they were mostly trivial and uninteresting mistakes. That said, I do want to point out how illuminating (and humbling) this process is: The number of mistakes you make while writing code can be surprisingly large.
It might seem like we're finished now, since we've written all of the features and all the tests pass, but there's one more step we should go through to see if our tests are thorough enough.
Code coverage tools are used to help you find untested code. They attach counters to each statement in the code, and alert you of any statements that are never executed. Code coverage is often expressed as a percentage; for example, 85 percent code coverage means that 85 percent of the statements in the code were executed.
If you have low code coverage, it’s usually a good indication that your tests are incomplete. Of course, having 100 percent code coverage is no guarantee that your unit tests can catch every potential bug, but in general, there are more defects in untested code than in tested code. Code coverage is an especially useful tool when writing tests for large projects that don't already have any unit tests.
For this we will need Node.js, so first install Node if you don't yet have it.
We’ll use Karma for running the code coverage tests. In the instructions below I assume that you have Chrome, but it's easy to modify which browser you use.
Create a file in your project called package.json
with the following content:
1{
2 "scripts": {
3 "test": "karma start my.conf.js"
4 },
5 "devDependencies": {
6 "jasmine-core": "^2.3.4",
7 "karma": "^0.13.21",
8 "karma-coverage": "^0.5.3",
9 "karma-jasmine": "^0.3.6",
10 "karma-chrome-launcher": "~0.1"
11 }
12}
Then create another file named my.conf.js
with the following content:
1module.exports = function(config) {
2 config.set({
3 basePath: "",
4 frameworks: ["jasmine"],
5 files: ["DateTime.js", "test/spec/*.js"],
6 browsers: ["Chrome"],
7 singleRun: true,
8 preprocessors: { "*.js": ["coverage"] },
9 reporters: ["progress", "coverage"]
10 });
11};
If you use Windows, open the Node.js command prompt. Otherwise, just open your terminal. Navigate to your project folder and run npm install
.
Once it's done installing, you can run npm test
whenever you want to run the coverage tests. It will create a coverage
folder with a subfolder corresponding to the name of your browser. Open the index.html
file in that folder to see the code coverage report.
The code coverage highlights unexecuted lines of code in red,
and unevaluated logical branches in yellow.
At this point, the code coverage report shows that the unit tests cover 96 percent of the lines of code and 87 percent of the conditional branches. Going through the report and inspecting the highlighted code reveals what our unit tests are missing:
There are no tests that use the default format string in the toString
method. We can add some to the describe("toString", ...)
section:
1it("uses YYYY-M-D H:m:s as the default format string", function() {
2 testDates.forEach(function(date, i) {
3 expect(date.toString()).toEqual(expectedStrings["YYYY-M-D H:m:s"][i]);
4 });
5});
There are no tests that try to set monthName
to an invalid month name. We can add some to the describe("setter", ...)
section:
1it("throws an error on attempt to set property `monthName` to an invalid value", function() {
2 var invalidMonths = ["janury", "???", "", 5];
3 invalidMonths.forEach(function(value) {
4 expect(function() {
5 var date = DateTime();
6 date.monthName = value;
7 }).toThrow();
8 });
9});
There are no tests that try to set ampm
to a value other than am
or pm
. We can add some to the describe("setter", ...)
section:
1it("throws an error on attempt to set property `ampm` to an invalid value", function() {
2 var invalidAMPM = ["afternoon", "p", "a", 0];
3 invalidAMPM.forEach(function(value) {
4 expect(function() {
5 var date = DateTime();
6 date.ampm = value;
7 }).toThrow();
8 });
9});
There are no tests that try to parse an invalid date string. We can add some to the describe("DateTime", ...)
section:
1it("throws an error when passed in an invalid date string and a format string", function() {
2 var invalidDates = [
3 "1234!5+3 1:2:3",
4 "tomorrow",
5 "Monday, January 500th 2000 5:40:30 pm",
6 0
7 ];
8 Object.keys(expectedStrings).forEach(function(format) {
9 invalidDates.forEach(function(invalidDate) {
10 expect(function() {
11 DateTime(invalidDate, format);
12 }).toThrow();
13 });
14 });
15});
Now the tests should cover 100 percent of the lines and branches of the code.
Congrats! If you've read through this far, you should have a basic idea of
This should be enough for you to get started with test-driven development in your own projects. If you work on collaborative projects, especially open-source ones, I would also recommend reading up on Continuous Integration (CI) testing. Travis CI is a popular CI server that automatically runs tests after every push to GitHub, and Coveralls similarly runs code coverage tests after every push to GitHub.
If you have any questions for me, feel free to leave a comment below.