How does TDD work?
Last time in our introduction to unit testing, we touched on TDD. Let’s talk about TDD and write a simple program that illustrates the steps.
The basic principal of TDD is to write your tests first and then write code to make the tests pass. Not only that, but in TDD, you write the smallest test that you can and the smallest amount of code to make that test pass. By doing this, you won’t end up with an irrelevant test suite that takes forever to write the code for. Also, you’ll know that your tests are actually testing properly: Especially when you first start writing tests, they may not work. By making sure that they fail and then pass, you’re making it more likely that you’re actually testing what you need to.
Even though we’re writing one test at a time, you’ll want to think of the design of the program before you start writing the tests so that you’re headed in the right direction and don’t have to do a bunch of refactoring. (Refactoring is a step of TDD, but you don’t want to take all your time doing it.)
So today, let’s write a simple program that will help you stay in touch with your friends. It will take a JSON input of a data structure of your friends with their name, a way to contact them, and date that you last contacted/were contacted by them. It will then return an object with the friend’s information that you talked to the longest ago.
Let’s start with the smallest part of the requirement: your program will return an object. We’ll set up our program file and our test file and then write a simple test to assert that the program to return an object.
Let’s start with friend-contact.js
in the root directory:
function friendContact(obj) {
}
module.exports = friendContact;
Then, in [rootDir]/test, create a file named friend-contact.spc.js
:
const assert = require('assert');
const friendContact = require('../friend-contact');
describe('friend contact', function () {
it('returns an object', function () {
assert.equal('object', typeof friendContact());
});
});
Now, if you have mocha installed globally (npm install mocha –g
), you should be able to run mocha
from your root directory and it will pick up your test in the test
directory. Because friendContact
is an empty function right now, it should fail:
Let’s go ahead and get this test to pass. Remember, we want to wright the minimum amount of code possible to get the test to pass, so we’ll just hard code friendContact
to return an object:
function friendContact(obj) {
return {}
}
module.exports = friendContact;
Now, run the test, and it should pass:
Now, this might seem like cheating to hard code an expected return like this. We’re not going to leave it this way and it’s important to note the benefit of doing the testing and development this way. If your tests ever start failing you won’t get much information if your one bigTestThatCoversEverythingFoo
fails. However, if you’ve got many tests that cover progressive requirements of your module, you’ll have an idea of where to start debugging if something goes wrong.
At this point, it’s usual in TDD to refactor the code to make it read or look or work better, but this is really straight ahead, so we won’t do that part here.
Now, let’s write our next test. We’ve already talked about error handling with input, so let’s make sure that our input is what we expect it to be. First, we know that it should take an object:
it('will not throw when passed an object', function () {
assert.doesNotThrow(friendContact.bind(null, {}));
// see my last post
// (http://jsunittesting.com/2017/10/23/writing-unit-tests-to-handle-unexpected-inputs/)
// about why you should use bind
});
Here, the test should pass, but let’s add error handling tests that won’t pass yet:
describe('bad input error handling', function() {
it('will throw when passed a boolean', function() {
assert.throws(friendContact.bind(null, false), /only object input/, 'wrong error message');
});
it('will throw when passed null', function() {
assert.throws(friendContact.bind(null, null), /only object input/, 'wrong error message');
});
it('will throw when passed undefined', function() {
assert.throws(friendContact.bind(null, undefined), /only object input/, 'wrong error message');
});
it('will throw when passed a number', function() {
assert.throws(friendContact.bind(null, 12), /only object input/, 'wrong error message');
});
it('will throw when passed a string', function() {
assert.throws(friendContact.bind(null, 'foo'), /only object input/, 'wrong error message');
});
it('will throw when passed a symbol', function() {
assert.throws(friendContact.bind(null, Symbol()), /only object input/, 'wrong error message');
});
});
Now run your tests and they should fail. Let’s handle the cases. Remember we’re looking for the quickest way to get our tests to pass. However, if we make the function always throw, our test that asserts that it won’t throw when passed an object will fail. It’s simple to check our input:
function friendContact(obj) {
if (typeof obj !== 'object' || obj === null ) throw new Error ('only object input');
// in JavaScript typeof null === ‘object’, so we need to check that case separately with the OR
return {};
}
Now, if you run the tests, you’ll see the new input handling tests passing. However, you’ll get a new failure: because we now expect friendContact
to be passed an object, our first test that passed no argument will now throw, making the test expecting the return of an object fail: (assert.equal('object', typeof friendContact());
). Let’s fix it: assert.equal('object', typeof friendContact({}));
Our tests should now all pass. Let’s start actually making our features. First, we need a way to pass test data into our tests. Let’s create a folder called test-data
inside the test
directory and add a file called friend-data.js
and add some test data:
const friendData = {
firstName: 'Clive',
lastName: 'Lewis',
phone: '44 01223 332129',
lastContact: '2017-11-29',
};
module.exports = friendData;
Now, let’s require it in our test file and use it to write a test:
const friendData = require('./test-data/friend-data');
//snip, add next test after the doesNotThrowtest
describe('returned output', function() {
it('returns an object', function () { // moved down from the top of the test
assert.equal('object', typeof friendContact({}));
});
it('returns an object with the expected data', function () {
assert.deepStrictEqual(friendData, friendContact(friendData));
});
});
Run your test, it should fail. Let’s make the test pass:
function friendContact(obj) {
if (typeof obj !== 'object' || obj === null ) throw new Error ('only object input');
return obj;
}
module.exports = friendContact;
Okay, now let’s start doing the work of making this do the lifting that we set out to do in the first place: returning one friend’s data that is the one that you contacted the longest ago. Let’s first build out friendData
with more friends. We’ll have an array of objects, each with a firstName
, lastName
, phone
and lastConatact
. Build it out with at least three friends and also export an object with the data of the friend you contacted longest ago:
const friendData = [
{
firstName: 'Clive',
lastName: 'Lewis',
phone: '44 01223 332129',
lastContact: '2016-11-29',
},
{
firstName: 'Martin',
lastName: 'Chemnitz',
phone: '49 345 5520',
lastContact: '2016-11-09',
},
{
firstName: 'Dieterich',
lastName: 'Buxtehude',
phone: '49 451 397700',
lastContact: '2017-05-09',
}
];
const solution = {
firstName: 'Martin',
lastName: 'Chemnitz',
phone: '49 345 5520',
lastContact: '2016-11-09',
}
module.exports = {
friendData,
solution
};
Now that we’ve restructured the data, we’ll want to run our tests to make sure that what we’ve got still passes and we don’t need to re-write anything. (Even though it passes now, you will want to change all of the friendData
to friendData.friendData
to reflect what’s going on.) Surprisingly, they still pass. Now let’s re-write our returned object test assert
ing that we’ll get the solution
if we give the function our new friendData
:
it('returns an object with the expected data', function () {
assert.deepStrictEqual(friendData.solution, friendContact(friendData.friendData));
});
As we expect, our test fails. Now, let’s build our function to behave the way we want. Because we’re now expecting arrays, I re-wrote the error handler to only take arrays which required a re-write of some of our tests:
function friendContact(inputArray) {
if (!Array.isArray(inputArray)) throw new Error ('only array input');
let friendIndex;
let lastContact;
inputArray.forEach((obj, index) => {
const currentObjDate = new Date(obj.lastContact);
if (!lastContact) lastContact = currentObjDate;
if (currentObjDate <= lastContact) friendIndex = index;
});
return inputArray[friendIndex] || {};
}
module.exports = friendContact;
Our test suite ended up like this:
const assert = require('assert');
const friendContact = require('../friend-contact');
const friendData = require('./test-data/friend-data');
describe('friend contact', function () {
it('will not throw when passed an array', function () {
assert.doesNotThrow(friendContact.bind(null, []));
});
describe('returned output', function() {
it('returns an object', function () {
assert.equal('object', typeof friendContact([]));
});
it('returns an object with the expected data', function () {
assert.deepStrictEqual(friendData.solution, friendContact(friendData.friendData));
});
});
describe('bad input error handling', function() {
it('will throw when passed a boolean', function() {
assert.throws(friendContact.bind(null, false), /only array input/, 'wrong error message');
});
it('will throw when passed null', function() {
assert.throws(friendContact.bind(null, null), /only array input/, 'wrong error message');
});
it('will throw when passed undefined', function() {
assert.throws(friendContact.bind(null, undefined), /only array input/, 'wrong error message');
});
it('will throw when passed a number', function() {
assert.throws(friendContact.bind(null, 12), /only array input/, 'wrong error message');
});
it('will throw when passed a string', function() {
assert.throws(friendContact.bind(null, 'foo'), /only array input/, 'wrong error message');
});
it('will throw when passed a non-array object', function() {
assert.throws(friendContact.bind(null, {}), /only array input/, 'wrong error message');
});
it('will throw when passed a symbol', function() {
assert.throws(friendContact.bind(null, Symbol()), /only array input/, 'wrong error message');
});
});
});
That’s a good place to stop. Try extending the app some more. Continue to start with tests that fail and write code to make them pass. Here are a few ideas for how you can extend the app:
- Verify that the friend object has the necessary data and throw if it doesn’t.
- Decide how to and deal with situations where two friends were both contacted the longest ago on the same day.
- Add additional contact methods (maybe email, fax), a preferred contact method and return just the preferred contact method and the name.
- Add a way to add friends.
- Add the ability to update when someone was contacted last.
That’s TDD. Let me know if you have questions!