Tests act like the quote that you give to the builder for work that you want to be done. When the builder comes back to you to say that he’s finished, you can check that against the quote and find that a few things were overlooked. When the work matches what was specified in the quote, only then is the job complete.
Because coding can be a complex venture, we don’t specify all of those tests up front, and instead add them one at a time so that we can easily improve the code to where it needs to be.
As an example, there is an addPlayer() function that is supposed to receive a <div class="video">
inside of which the video is shown. That function cannot work properly when it gets other types of things, so we need to protect that function from other types of inputs.
In these examples I will be using the Jasmine test framework, and have color-coded the headings using pastel colors of red, green, and blue, to represent the three stages of doing test-driven-development, which is red for the test, green for making the test pass, and blue for refactoring. Each stage is achieved fairly rapidly, and you end up cycling through each of those stages like a whirling dervish.
1. A failing test: addPlayer() needs a function argument
We have an addPlayer() function that needs to complain and throw an error when it’s given an unsuitable HTML element. It needs to be called with an HTML element that has a classname of “video”.
We start with a nice and simple test, that says that the addPlayer() function should complain when it is called with no arguments.
it("must be called with a function argument", function () {
function noArgument() {
videoPlayer.addPlayer();
}
expect(noArgument).toThrow();
});
2. Pass the test: Throwing an error
We can have the addPlayer() function throw an error just by adding a single line to the start of it:
function addPlayer(video) {
throw new Error();
...
}
But that causes other tests that expect the function to work to break, so we need to wrap that error in an if statement so that it only gets run when the video parameter is undefined.
function addPlayer(video) {
if (!video) {
throw new Error();
}
...
}
The tests pass, including the one that we added above, and things are good.
3. Refactor: Important to check
Refactoring is what gets checked now. Currently nothing needs to be refactored yet, but it’s important not to skip over this part of the process and to always check if something can be improved.
1. A failing test: Using TypeError instead
Right now the error that’s being given is just a bland Error warning. There are many types of errors that can occur, and from that list the TypeError is most suitable. We can update the original test where instead of using toThrow(), we use the toThrowError() matcher to specify which error is expected.
it("must be called with a function argument", function () {
function noArgument() {
videoPlayer.addPlayer();
}
// expect(noArgument).toThrow();
expect(noArgument).toThrowError(TypeError);
});
2. Pass the test: Update addPlayer() with the new error
Making this test pass is as easy as replacing Error for TypeError.
function addPlayer(video) {
if (!video) {
// throw new Error();
throw new TypeError();
}
3. Refactor:
Comments have been used in the code examples to comment out old lines, and help provide a comparison between the old and the new. Any such commented-out code can be removed.
Nothing else looks like it need refactoring yet at this stage.
1. A failing test: Explaining the error
When the error occurs it helps tremendously when you are informed about what is expected there instead.
We can update the original test so that we specify which error message is required.
it("must be called with a function argument", function () {
function noArgument() {
videoPlayer.addPlayer();
}
// expect(noArgument).toThrow();
expect(noArgument).toThrowError(TypeError, "video is not an HTML Element.");
});
2. Pass the test: Add the error message
Making this test pass is as easy as replacing Error for TypeError.
function addPlayer(video) {
if (!video) {
// throw new TypeError();
throw new TypeError("video is not an HTML Element.");
}
3. Refactor:
No refactoring is needed at this stage, other than removing any commented-out code that remains.
e will have some refactoring to do soon though.
1. A failing test: Needs an HTML element
Functions can be called with strings and numbers and arrays and a wide manner of other arguments, so we need to ensure that the addPlayer() function is called with an HTML element instead.
it("must be called with an HTML element", function () {
function noHTMLArgument() {
videoPlayer.addPlayer("A string instead of an html element");
}
expect(noHTMLArgument).toThrowError(TypeError, "video is not an HTML Element.");
});
2. Pass the test: Update the condition
The simple way to make this pass is to add another condition with another error, after the one that we already have in the addPlayer() function.
It’s important to get the test to pass early and simply, even when it’s not fully proper code, as it is during the refactoring stage that we then improve the code.
function addPlayer(video) {
if (!video) {
throw new TypeError("video is not an HTML Element.");
}
if (video.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError("video is not an HTML Element.");
}
3. Refactor: Combine the if statements and extract
We have used low-quality code to get the test passing. Now is the time during refactoring when we improve that code.
The if statements can be combined so that only one if statement is used.
function addPlayer(video) {
// if (!video) {
if (!video || video.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError("video is not an HTML Element.");
}
// if (video.nodeType !== Node.ELEMENT_NODE) {
// throw new TypeError("video is not an HTML Element.");
// }
...
}
Do we we even need that initial !video anymore? Let’s try to remove it:
function addPlayer(video) {
// if (!video && video.nodeType !== Node.ELEMENT_NODE) {
if (video.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError("video is not an HTML Element.");
}
...
}
The test fails, and we are given the following error message:
TypeError: Cannot read properties of undefined (reading 'nodeType')
That !video part is needed to protect against an undefined variable.
We can simplify the if statement though by extracting the condition out to a separate function:
function isHTMLElement(el) {
return !el && el.nodeType !== Node.ELEMENT_NODE;
}
function addPlayer(video) {
// if (video.nodeType !== Node.ELEMENT_NODE) {
if (isHTMLElement(video)) {
throw new TypeError("video is not an HTML Element.");
}
...
}
And with function names, it’s easier to understand them when they represent a positive thing rather than a negative thing, so I’ll rename it to isHTMLElement(). As for the condition I’ll check !isHTMLElement(video)
which forces me to invert things inside of the function too.
// function notAnHTMLElement(el) {
// return !el && el.nodeType !== Node.ELEMENT_NODE;
// }
function isHTMLElement(el) {
return el && el.nodeType === Node.ELEMENT_NODE;
}
function addPlayer(video) {
// if (notAnHTMLElement(video)) {
if (!isHTMLElement(video)) {
throw new TypeError("video is not an HTML Element.");
}
...
}
That’s better. Now the function is checking if param exist before using it to check the nodeType, which is a good protective behaviour.
We can remove the need for that check though, by giving the parameter a default value.
// function isHTMLElement(el) {
function isHTMLElement(el = {}) {
// return el && el.nodeType === Node.ELEMENT_NODE;
return el.nodeType === Node.ELEMENT_NODE;
}
function addPlayer(video) {
if (!isHTMLElement(video)) {
throw new TypeError("video is not an HTML Element.");
}
...
}
We could even move that if statement out to a separate function, but that doesn’t really need doing just now. I’m on the edge about doing that. We do have plans for more that needs to be checked though, so moving things out can be delayed until that occurs.
1. A failing test: Needs a class name
When writing tests you shouldn’t “go for the gold” by first for testing what is your ultimate goal. The goal here is for an error message to occur, warning you about HTML elements that don’t have a classname of video. Instead of testing for that goal straight from the start, we creep up on it by doing a small piece at a time, thematically checking the ground all around it, checking for pitfalls and traps, until there is nothing else to do but confirm that our goal has been is achieved.
Now that only HTML elements are accepted, we want to restrict that further so that only HTML elements with a classname of “video” are accepted.
it("must be called with a video classname", function () {
function missingClassname() {
const badVideo = document.createElement("div");
videoPlayer.addPlayer(badVideo);
}
expect(missingClassname).toThrowError(TypeError, "Element needs a video classname.");
});
2. Pass the test: Check that the video class exists
The addPlayer() function is supposed to throw an error when the video
classname isn’t there, so we now need to check for that.
We can add that check below the existing if statement:
function addPlayer(video) {
if (!isHTMLElement(el)) {
throw new TypeError("video is not an HTML Element.");
}
if (!el.classList.contains("video")) {
throw new TypeError("Element needs a video classname.");
}
...
}
The test now passes, so it’s on to refactoring.
3. Refactor: Extract code to a separate function
This is a good time now to move code out to a separate checkHasVideoClassname() function.
function checkHasVideoClassname(el) {
if (!isHTMLElement(el)) {
throw new TypeError("video is not an HTML Element.");
}
if (!el.classList.contains("video")) {
throw new TypeError("Element needs a video classname.");
}
}
function addPlayer(video) {
checkHasVideoClassname(video);
...
}
I want to combine those if statements though, which is only easily done when the one error message is used. I’m going to update the tests so that “Element needs a video classname.” is the same consistent message that’s given.
1. A failing test: Use the same error message
The first few tests get updated so that they all use the “Element needs a video classname.” error message.
it("must be called with a function argument", function () {
...
// expect(noArgument).toThrowError(TypeError, "Element needs a video classname.");
expect(noArgument).toThrowError(TypeError, "Element needs a video classname.");
});
it("must be called with an HTML element", function () {
...
// expect(noHTMLArgument).toThrowError(TypeError, "Element needs a video classname.");
expect(noHTMLArgument).toThrowError(TypeError, "Element needs a video classname.");
});
2. Pass the test: Update the error message
function checkHasVideoClassname(el) {
if (!isHTMLElement(el)) {
// throw new TypeError("video is not an HTML Element.");
throw new TypeError("Element needs a video classname.");
}
...
}
3. Refactor: Combine if statements and other improvements
Now that the error messages are the same, we can combine the if statement logic together.
function checkHasVideoClassname(el) {
// if (!isHTMLElement(el)) {
// throw new TypeError("Element needs a video classname.");
// }
// if (!el.classList.contains("video")) {
if (!isHTMLElement(el) || !el.classList.contains("video")) {
throw new TypeError("Element needs a video classname.");
}
}
That is getting quite complicated for the if statement condition though. I want to simplify it, so to help achieve that we can extract the condition out to a separately named variable:
function checkHasVideoClassname(el) {
// if (!isHTMLElement(el) || !el.classList.contains("video")) {
const missingVideoClassname = !isHTMLElement(el) || !el.classList.contains("video");
if (missingVideoClassname) {
throw new TypeError("Element needs a video classname.");
}
}
Going back to that idea where negative-based names are bad, I want to invert that missingVideoClassname name so that it is positive instead. I can do that by inverting what it checks.
When there is a check that is of the structure !a || !b, that is the same as !(a && b). I’ll do that invert to the code, and judge things from there.
function checkHasVideoClassname(el) {
// const missingVideoClassname = !isHTMLElement(el) || !el.classList.contains("video");
const missingVideoClassname = !(isHTMLElement(el) && el.classList.contains("video"));
if (missingVideoClassname) {
throw new TypeError("Element needs a video classname.");
}
}
We can now rename missingVideoClassname to hasVideoClassname and remove the invert that’s wrapping the statement.
function checkHasVideoClassname(el) {
// const missingVideoClassname = !(isHTMLElement(el) && el.classList.contains("video"));
const hasVideoClassname = isHTMLElement(el) && el.classList.contains("video");
// if (missingVideoClassname) {
if (!hasVideoClassname) {
throw new TypeError("Element needs a video classname.");
}
}
A final improvement is to pass the desired classname to the function, so that other functions might be able to use it too.
// function checkHasVideoClassname(el) {
function checkHasClassname(el, classname) {
// const hasVideoClassname = isHTMLElement(el) && el.classList.contains("video");
const hasClassname = isHTMLElement(el) && el.classList.contains(classname);
// if (!hasVideoClassname) {
if (!hasClassname) {
// throw new TypeError("Element needs a video classname.");
throw new TypeError(`Element needs a ${classname} classname.`);
}
...
function addPlayer(video) {
// checkHasClassname(video, "video");
checkHasVideoClassname(video, "video");
There is still the minor issue that video
and “video” are being used on the same line, for which I would want to clarify that the video
variable is a container inside of which youtube will create an iframe for the video. That can be approached by renaming video to videoContainer instead.
// function addPlayer(video) {
function addPlayer(videoContainer) {
// checkHasClassname(video, "video");
checkHasClassname(videoContainer, "video");
...
// player = new YT.Player(video, config);
player = new YT.Player(videoContainer, config);
That’s quite the improvement, which leaves us with the following code:
function isHTMLElement(el = {}) {
return el.nodeType === Node.ELEMENT_NODE;
}
function checkHasClassname(el, classname) {
const hasClassname = isHTMLElement(el) && el.classList.contains(classname);
if (!hasClassname) {
throw new TypeError(`Element needs a ${classname} classname.`);
}
}
function addPlayer(videoContainer) {
checkHasClassname(videoContainer, "video");
...
player = new YT.Player(videoContainer, config);
...
}
Summary
The above code helps to demonstrate that tests are fundamental in helping to build your code, and that it can be a rather fast development cycle. Each step is fairly easy to achieve, and because it is tests that cause code to be written, there is no fear in changing code because while the tests continue to pass, we can be sure that we haven’t broken anything that we yet care about.
The final code from the above exercise is at https://jsfiddle.net/pmw57/pyt72dah/2/
But a warning to the person that I am helping in another thread - the code at that link is not necessarily the same as what we are working towards, so please don’t be tempted to copy/paste from here as that is only going to slow things down.
To everyone else, good luck out there. I hope that this brief look at using tests to develop code has been useful.