I kind of never really saw the bigger picture with unit testing until I watched an excellent YouTube video by Kyle over at Web Dev Simplified (by the way, follow this guy, his videos are excellent!)
NOW I get it…. ok, so for small projects of say 1000 lines of code or less, it’s probably not necessary, as it’s fairly straightforward to skim through the code and identify the issues, especially if you’ve been adding console logs along the way that help confirm things are working as expected. However if you’re involved is medium to high complexity projects over thousands and thousands of lines of code, and/or your working as part of a team, then unit testing becomes really important.
Here are a couple of variables, and a few functions….it’s a very silly example, but at least it hopefully explains the importance of unit testing:
(Important note: no errors at all are reported with this code in VS Code!)
const word3 = 'brush';
const word4 = 'pick';
function checkLength(word3) {
if (word4.length > 4) {
return true;
} else {
return false;
}
}
function checkIfWordContainsI() {
if (word3.includes('i')) {
return true;
} else {
return false;
}
}
function concatTwoWords(word1, word2) {
return word1 + word3;
}
if I called checkLength() without an argument, what response would I get? true or false?
checkLength()
// false
what about if I pass in word 3 as the argument when I call it?
checkLength(word3)
// false
It doesn’t matter either way, because the function is checking the if statement on word 4, which happens to be “pick”, so will always return false.
To some that might seem obvious, but if you’re new to JavaScript, something like that might not be too easy to spot, especially when you don’t have VS code highlighting any errors, so you’re not actually sure where you’re meant to be looking.
What about the second function? What if I call function checkIfWordContainsI() and pass in the word “island” ? Would I get true or false based on the code above?
checkIfWordContainsI("island")
Once again, if you’re an experienced Javascript programmer, it’s probably obvious that this would return “false”, but if you’re new to programming, it might not be so obvious that it doesn’t matter what you try to pass in to this function, the fact is that it’s still checking if word3 (i.e. “brush”) contains a letter i, which of course it doesn’t.
Let’s just quickly look at the last function:
function concatTwoWords(word1, word2) {
return word1 + word3;
}
Ok so it’s expecting two arguments, and will place them together…. so what will the console return if I call this function with the following arguments?
concatTwoWords("tooth", "paste")
If your answer was “toothbrush”, then congratulations, you are good at spotting things that might not be obvious to some!
The issue here is that someone could’ve accidentally typed in this function wrong…. what they meant to type in was
function concatTwoWords(word1, word2) {
return word1 + word2;
}
when in fact they typed in:
function concatTwoWords(word1, word2) {
return word1 + word3;
}
Now because word3 has already been declared as a valid string, JavaScript will correctly assume you want to concatenate word1 (which is the first argument you passed in) with word3 (which is the declared variable of “brush”.
So JavaScript did EXACTLY what your code asked it to! No errors are reported, because technically there’s nothing wrong here….it just so happens that you might be seeing something that you weren't expecting, and once again, because there's no code error, it would be difficult to find in a project with thousands and thousands of lines of code, especially if the function was far more complex, and contained multiple if/else statements!
This is what unit testing solves!
Using Jest
Unit testing essentially allows you to write out tests for all your functions, so that you can confirm that the results will be what you expect them to be…every time. If any test fails, then you know exactly which function to go back and check carefully. Going back to the concatTwoWords function example, you may be expecting the function to ALWAYS return a concatenation of two words that are passed into the function, and if they don’t, then somethings not right. Perhaps you’re working on a project with another coder, who accidentally changed word2 to word3, or perhaps it was a slip of the finger when typing….the point is that you are notified of where an issue is, and can go and fix it.
So let’s see how we could write a very basic unit test for the concatTwoWords function…
First we need to ensure that we have a package.json file in our project folder. If we don’t already have one, we can run:
npm init -y
The -y
flag in the npm init -y
command is used to initialize a new package.json
file without prompting you for any input. It stands for "yes," and it automatically accepts all the default values when generating the package.json
file.
Next we can install Jest using NPM into our current directory:
npm install --save-dev jest
I would also strongly suggest installing jsdom as well, and I’ll explain why it could be useful shortly….
npm i jest-environment-jsdom --save-dev
ok now we have our packages installed, we can set our package.json file to use jest by including the following:
"scripts": {
"test": "jest"
},
with this in place, we should be able to run “npm test” from the command line.
The next thing we want to do is test our function. Assuming our script.js file is as follows…..
const word3 = 'brush';
const word4 = 'pick';
function checkLength(word3) {
if (word4.length > 4) {
return true;
} else {
return false;
}
}
function checkIfWordContainsI() {
if (word3.includes('i')) {
return true;
} else {
return false;
}
}
function concatTwoWords(word1, word2) {
return word1 + word3;
}
then we can run tests on all three functions, or just focus on the one we want to test. In this case we’ll focus just on concatTwoWords() so we’ll specify this as the function to export:
const word3 = 'brush';
const word4 = 'pick';
function checkLength(word3) {
if (word4.length > 4) {
return true;
} else {
return false;
}
}
function checkIfWordContainsI() {
if (word3.includes('i')) {
return true;
} else {
return false;
}
}
function concatTwoWords(word1, word2) {
return word1 + word3;
}
// export the function we want to test
module.exports = { concatTwoWords };
Next we’ll create a script.test.js file, with the following content:
// our script.test.js file
const { concatTwoWords } = require('./script');
test('concatenates two words that are passed into the function', () => {
concatTwoWords((word1, word2) => word1 + word2);
// Test the function
expect(concatTwoWords('hello', 'world')).toBe('helloworld');
});
So we use the word test to define the test we want to make…. the first argument is simply a string that allows you to explain what you are trying to test, and the second argument is a function that expresses the arguments the function should expect, and then what it should actually do, so in the example above, it takes two arguments (word1, word2) and adds them together.
We then write our “expect” code, which says that we expect that if we pass in two strings of “hello” and “world” into our function, then we should get “helloworld” to be returned.
With that in place, lets now run our test….
npm test
Here is the response….
FAIL ./script.test.js
× concatenates two words that are passed into the function (9 ms)
● concatenates two words that are passed into the function
expect(received).toBe(expected) // Object.is equality
Expected: "helloworld"
Received: "hellobrush"
6 |
7 | // Test the function
> 8 | expect(concatTwoWords('hello', 'world')).toBe('helloworld');
| ^
9 | });
at Object.toBe (script.test.js:8:44)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 1.011 s
Ran all test suites.
So as you can see, we were expecting “helloworld” but we did in fact get back “hellobrush”
We can now go back to our function to investigate, and at this point we should be able to see why it’s adding “brush” — It’s because the function is attempting to concatenate word1 (correct) with word3 (incorrect).
Lets fix our function, and run the test again:
function concatTwoWords(word1, word2) {
return word1 + word2;
}
npm test
PASS ./script.test.js
√ concatenates two words that are passed into the function (5 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.969 s, estimated 1 s
Ran all test suites.
So as you can see, the test has helped us find what could potentially be something tricky to troubleshoot, as there were no errors ever reported in VS Code. Hopefully it’s obvious how useful this could be on very large, complex projects.
.toBe (or .not.ToBe) — that is the question!
You may have noticed that we used .toBe as part of our expect statement. This is known as a “matcher”
.toBe is used to to compare primitive values such as strings or numbers, but will return errors if you wanted to compare arrays or objects.
For example, lets create another simple function called myArray() and then export it:
function concatTwoWords(word1, word2) {
return word1 + word2;
}
function myArray(array) {
return [...array];
}
// export the function we want to test
module.exports = {
concatTwoWords,
myArray,
};
The array parameter is passed to the myArray() function.
[…array] creates a new array containing all the elements of the array parameter. It effectively duplicates the contents of the original array.
If you ran this code normally, you’d get the following:
myArray([1,2,3,4,5])
(5) [1, 2, 3, 4, 5]
so you can see it’s returned an array of the same values in the same order as what was passed into the function.
Let’s now test this function with the .toBe matcher as another test….
const { concatTwoWords, myArray } = require('./script');
test('concatenates two words that are passed into the function', () => {
// Set up the mock implementation for concatTwoWords
concatTwoWords((word1, word2) => word1 + word2);
// Test the function
expect(concatTwoWords('hello', 'world')).toBe('helloworld');
});
test('correctly clones the array passed in', () => {
//define an array to pass into the function
const array = [1, 2, 3, 4, 5];
// Test the function
expect(myArray(array)).toBe(array);
});
Here is the return after we run npm test…
FAIL ./script.test.js
√ concatenates two words that are passed into the function (4 ms)
× correctly clones the array passed in (7 ms)
● correctly clones the array passed in
expect(received).toBe(expected) // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"
Expected: [1, 2, 3, 4, 5]
Received: serializes to the same string
15 |
16 | // Test the function
> 17 | expect(myArray(array)).toBe(array);
| ^
18 | });
19 |
at Object.toBe (script.test.js:17:26)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 passed, 2 total
Snapshots: 0 total
Time: 0.981 s, estimated 1 s
Ran all test suites.
So we can see that it passed the first test, but not the second. The second test expected [1, 2, 3, 4, 5] but the return was a shallow copy of the same array, not THE original array. The two arrays are basically objects stored in different memory locations, and so are different, even though they look identical.
In order to get this type of test to pass, we’d have to replaced the matcher or .toBe with .toEqual which will check if the values in the array are the same, not necessarily that the arrays are the same array. We’ll run again with .toEqual
const { concatTwoWords, myArray } = require('./script');
test('concatenates two words that are passed into the function', () => {
// Set up the mock implementation for concatTwoWords
concatTwoWords((word1, word2) => word1 + word2);
// Test the function
expect(concatTwoWords('hello', 'world')).toBe('helloworld');
});
test('correctly clones the array passed in', () => {
//define an array to pass into the function
const array = [1, 2, 3, 4, 5];
// Test the function
expect(myArray(array)).toEqual(array);
});
which now returns…
PASS ./script.test.js
√ concatenates two words that are passed into the function (4 ms)
√ correctly clones the array passed in (1 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
There are plenty of other “matchers” that can be used, with more details available on Jest’s website: https://jestjs.io/docs/getting-started
jsdom
I mentioned jsdom earlier because I ran into an issue when trying out Jest, and it took me a while to figure out what the problem was.
Initially my script.js file also contained other things, not just the three functions I was planning on testing. This caused the following error to be generated whenever I ran tests….
This was a snippet of my script.js file, with the functions I wanted to test with jest near the bottom….
document.querySelector('.again').addEventListener('click', () => {
let guessValue = parseInt(document.querySelector('.guess').value);
if (guessValue === secretNumber) {
document.querySelector('.number').textContent = '?';
document.querySelector('body').style.backgroundColor = '#000000';
if (Number(score) > Number(highScore)) {
highScore = score;
document.querySelector('.highscore').textContent = highScore; // Update the high score in the UI
}
score = 10;
document.querySelector('.guess').value = '';
document.querySelector('.message').textContent = 'Make a guess';
}
});
const word3 = 'brush';
const word4 = 'pick';
function checkLength(word3) {
if (word4.length > 4) {
return true;
} else {
return false;
}
}
function checkIfWordContainsI() {
if (word3.includes('i')) {
return true;
} else {
return false;
}
}
function concatTwoWords(word1, word2) {
return word1 + word2;
}
function myArray(array) {
return [...array];
}
function myObject(object) {
return { ...object };
}
// export the function we want to test
module.exports = {
concatTwoWords,
myArray,
myObject,
};
As you can see, at the top of the file, I had some code for something else, not just pure functions…. whenever I ran npm test, I was seeing this error:
FAIL ./script.test.js
● Test suite failed to run
The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
Consider using the "jsdom" test environment.
ReferenceError: document is not defined
46 | // });
47 |
> 48 | document.querySelector('.again').addEventListener('click', () => {
| ^
49 | let guessValue = parseInt(document.querySelector('.guess').value);
50 |
51 | if (guessValue === secretNumber) {
at Object.document (script.js:48:1)
at Object.require (script.test.js:1:47)
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.002 s
Ran all test suites.
So I could see a reference to using a jsdom test environment instead of via node.
In order to do this, I first had to install the jsdom package (see at the start of this medium posting) and then had to modify my package.json file to be as follows:
"scripts": {
"test": "jest --watchAll --config=jest.config.json"
},
So I’m basically saying that I want jest to use a jest.config.json file
Next I had to create the jest.config.json file, and add the following to it:
"scripts": {
"test": "jest --watchAll --config=jest.config.json"
},
now when I ran npm test I was seeing a different error:
FAIL ./script.test.js
● Test suite failed to run
TypeError: Cannot read properties of null (reading 'addEventListener')
46 | // });
47 |
> 48 | document.querySelector('.again').addEventListener('click', () => {
| ^
49 | let guessValue = parseInt(document.querySelector('.guess').value);
50 |
51 | if (guessValue === secretNumber) {
at Object.<anonymous> (script.js:48:33)
at Object.require (script.test.js:1:47)
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 2.938 s
Ran all test suites.
This had me stuck for a while, but evenbtually I got past this by ensuring that the code causing the problem was wrapped with a listener for ‘DOMContentLoaded’
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('.again').addEventListener('click', () => {
let guessValue = parseInt(document.querySelector('.guess').value);
if (guessValue === secretNumber) {
document.querySelector('.number').textContent = '?';
document.querySelector('body').style.backgroundColor = '#000000';
if (Number(score) > Number(highScore)) {
highScore = score;
document.querySelector('.highscore').textContent = highScore; // Update the high score in the UI
}
score = 10;
document.querySelector('.guess').value = '';
document.querySelector('.message').textContent = 'Make a guess';
}
});
});
PASS ./script.test.js
√ concatenates two words that are passed into the function (11 ms)
√ correctly clones the array passed in (1 ms)
√ correctly clones the object passed in
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.836 s
Ran all test suites.
Watch Usage: Press w to show more.
DOMContentLoaded is an event in the Document Object Model (DOM) of web browsers. It is fired when the initial HTML document has been completely loaded and parsed, without waiting for stylesheets, images, and other external resources to finish loading. In other words, it signals that the HTML structure of a web page is ready to be manipulated and interacted with using JavaScript.
So what was happening was that the event listener code was being executed before the DOM had been fully loaded in the test environment.