PBS 135 of X: Introducing Jest (and re-Introducing Test Driven Development & Unit Testing)
Way back in instalment 33 I introduced the concepts of test driven development (TDD) and unit testing, and we put theory into practice using the open source testing framework QUnit. While the fundamental concepts of TDD and unit testing haven’t changed since then, JavaScript has, and so indeed have we! JavaScript has gained promises, classes, and most recently modules, and we now know a lot more about a lot more, and we have a heck of a lot more experience putting our knowledge to use!
Outside of this series I’ve fallen out of love with QUnit. In my day job I had been trying to practice what I preach — I was writing test cases together with code, and testing each part of a project as I built it up, but it was a chore, and when the pressure came on, the tests fell away. Once you get behind on writing your tests, getting caught up again is a massive chore, and it’s very hard to justify the time and energy to your manager.
On the one hand, I don’t miss the drudgery of writing those horribly repetitive test cases, but on the other hand, I now remember why I wanted to learn unit testing in the first place! While I was unit testing I spent very little time chasing down regression bugs. I obviously still made mistakes, but my test suites would catch them before I even committed the code, let alone pushed it into production. Without that safety net I found myself spending a lot more time tracking down weird bugs, and yes, causing regressions (accidentally allowing once-fixed problems back into the code base). I guess those software engineers knew what they were doing after all!
I’ve been looking for a good reason to justify spending the time to find a better alternative to QUnit for some time now, and the re-platforming of the Crypt::HSXKPasswd Perl module to JavaScript is the perfect opportunity. I want HSXKPasswdJS
to be built on a solid foundation so the codebase can have a long and fruitful life. That’s why the API will be built, from day one, using three important software engineering tools:
- A good linter to spot common bugs, nip bad practice in the bud, and keep the code nice and consistent — hence inviting Helma onto the show to teach us all ESLint in instalment 129. ✅
- A good documentation generator, because good documentation is written with the code, not afterwards. That’s why I introduced JSDoc in recent instalments. ✅
- A good testing suite, one that adds less friction to the process than QUnit did. ⚠️
After a lot of dithering, I finally picked one — Jest.
Matching Podcast Episode
Listen along to this instalment on episode 715 of the Chit Chat Across the Pond Podcast.
You can also Download the MP3
Episode Resources
- The instalment ZIP file — pbs135.zip.
A Quick Unit Testing Refresher
Before we dive into Jest, let’s remind ourselves of both the problem we’re trying to solve, and software engineering technique we’ll be guided by as we do it. I want to be very clear, I see both test driven development and unit testing as guiding philosophies more than rigid methodologies.
When it comes to TDD the single most important point is that tests have to be built with the code, not tacked on afterwards. In fact, the tests for each chunk of code should be written before the code. I like to think of the test suite as being the scaffolding around a skyscraper that’s under construction, it rises with the skyscraper, but just a little bit ahead. The reason you write your test first is that you want to see each test fail before you write the code to make it pass. This is important because it gives you confidence that your test really works, what good is a test that always passes, even when the code is wrong‽
Test driven development assumes humans make mistakes, which is an excellent assumption! If you follow that logic to its obvious conclusion, that means your test suite will be imperfect too — does that not defeat the purpose? Well firstly, if your test suite finds only half your bugs, it’s still found half your bugs! And secondly, your test suite is a permanent part of your code, as you debug it, you benefit from your fixes forever. If you find you’re missing a test for an important edge case, once you add that test, you have it forever, and no future code that breaks that edge case will ever pass the tests again. I think of it like a ratchet moving your code inevitably towards being more robust and reliable.
Unit testing is a much simpler concept — every logical chunk of your code should have its own dedicated set of tests, and the structure of your test suite should mirror the structure of your code. For us in JavaScript land that means tests for each function, class, and module, defined in matching nested groupings.
Testing Tips
A test suite is of course only as good as the tests within it, and like writing good code, writing good tests is an art more than a science. Over time you’ll learn what you absolutely need to test for, so your initial implementations will improve. Remember though that test suites are expected to improve over time, so don’t stress about imperfections, you’ll iron them out over time!
With that said, I do have some tips to help improve your initial implementations.
For modules, check that they export the expected values.
For classes, make sure the constructor handles no arguments, all valid combinations of arguments, and invalid arguments, check that all getters return expected values, and that all setters correctly set new values, and reject invalid values.
For functions, check the data validation for all arguments, check that they throw the expected exceptions, and verify the returned value. For function return values, make sure they return the right values for each edge case you can think of.
Finally, when ever you find a bug that sneaks through your test suite, use that as an opportunity to improve your test suite. Add the test, make sure it flags the problem, then patch the bug and re-run the test to verify your fix.
Choosing a Testing Framework
The reason I spent so long procrastinating about which test suite to use for this project is because I know I’m making a long-term commitment. Since the tests built with the code, replacing the test suite later would be a lot of very menial work.
What ever I choose it going to be what I’m stuck with for the lifetime of the project, so best not choose poorly!
My criteria were simple:
- Good support for modern JavaScript, specifically classes and promises.
- Native support for ES6 modules (that proved a much bigger hurdle than you might imagine).
- A developer-friendly syntax that’s short to write yet easy to read and understand.
- A project that has good documentation and is under active development — I don’t want to hitch my wagon to a dying horse!
- A project that’s earned enough respect within the developer community that it’s in widespread use and discussed widely — no point in learning something niche, those hard-won skills won’t transfer, and it will be much harder to find help online.
If native ES6 module support were not an absolute requirement there would be plenty of options to choose between, but just about all JavaScript testing frameworks are built around NodeJS, and since NodeJS is late to the ES6 module party, so are most of the testing frameworks.
While there are many alternatives to Jest that do support ES6 modules in some way, they generally do it in a cumbersome way I find very off-putting. To work around inconsistencies in browsers, a code translation tool named Babel was invented. Babel takes JavaScript code written using one set of APIs, and output code that theoretically does the same thing using a different set of APIs. Originally this was used to write code using the ECMA JavaScript APIs, and translating it into code that worked in IE too. Another common use for Babel was translating between the different third-party implementations of promises before browser support for the official Promise API became wide-spread. Now, Babel is commonly used to translate JavaScript module import statements between the ES and CommonJS syntaxes.
What this means for the testing frameworks that support ES6 modules via Babel is that the code you write, and the code the tester executes are not the same. Your code is translated by Babel before being executed, so you can’t see what the test suite sees, and line numbers probably won’t line up either. To me it’s the worst kind of spooky action at a distance, and I just don’t want that kind of confusion in my life!
So, by ruling out Babel, there was only one choice left — Jest:
- Support for modern JS ✅
- Native support for ES6 modules ✅
- Nice syntax — you’ll need to judge for yourself, but I like it 🙂
- Good documentation — again, you’ll need to judge for yourself, but I can find what I need when I need it, so I’m content 👍
- Well-supported & widely adopted — it’s an open source project originating within Facebook, and they’re still using it today, along with other big names like Twitter, the NewYork Times, and AirBnB. I found it very easy to Google my way out of my teething problems, so I’m confident it has a long future and isn’t in any way niche 👍
Native but Nascent (ES6 Module Support)
While Jest is the only contender that supports ES6 modules without Babel, that support is still very new. In fact, officially, it’s experimental. I generally don’t like working this close to the cutting edge in this series, but when the choice is experimental support or no support at all, experimental support wins 🙂
In your mind, I want you to hear Allison’s favourite refrain — everything is fiddly. For Jest to work with ES6 modules we have to set up our projects just right. The path to success is not treacherous, but it is narrow!
To get Jest to work for us, we need to do the following:
- Use Jest within a NodeJS project folder (a folder with a
package.json
) file. - Install Jest into the NodeJS project as a dev dependency (
npm install --save-dev jest
). - Configure the NodeJS project to treat
.js
files as ES6 modules by adding the top-level key"type": "module"
topackage.json
. - Define our test script in
package.json
in such a way that it invokes NodeJS in ES6 mode. There are a few different ways to do this, but we’ll be using theNODE_OPTIONS
environment variable.
A Worked Example — Testing our Joiner
In the previous instalment we wrote a simple ES6 module for joining arrays of strings like a human would, with a final conjunction between the last two items. We’re going to add a Jest-powered test suite to that module.
Our starting point will be the code as it was at the end of the previous instalment, contained within a NodeJS project folder configured to use both ESLint and JSDoc. You’ll find this starting point in the folder pbs135a-joiner-before
in this instalment’s ZIP. If you want to play along, open the folder in the terminal and initialise Node by running npm ci
to get ESLint and JSDoc installed. Note that you don’t need to play along, the finished version with Jest installed and a test suite added is included in the Zip as the folder pbs135b-joiner-after
.
Installing & Configuring Jest
For reference, here’s our package.json file before installing or configuring Jest:
{
"name": "pbs135a-joiner",
"version": "1.0.0",
"description": "An ES6 module for joining arrays in a human-friendly way for use as a worked example Programming by Stealth instalment 135",
"scripts": {
"docs": "docDir='./docs'; [[ -e \"$docDir\" ]] && rm -rf \"$docDir/\"*; npx jsdoc -c ./jsdoc.conf.json --destination \"$docDir/\" --template node_modules/docdash",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"JavaScript",
"Jest",
"Programming",
"by",
"Stealth",
"Example"
],
"author": "Bart Busschots",
"license": "ISC",
x
}
To add Jest as a dev dependency and install it into node_modules
use the command:
npm install --save-dev jest
With Jest installed we need to configure it. You can use a separate Jest config file, but it’s much easier to add Jest’s config directly into package.json
by adding a top-level key named jest
.
The minimum config we need is:
"jest": {
"testEnvironment": "jest-environment-node",
"transform": {}
}
This tells Jest we’re using NodeJS, and to explicitly disable all code transformations, i.e. to make sure Babel is off by setting the list of transformations to an empty dictionary.
To allow Jest load its tests from .js
files we need to be sure the Node package is configured for ES6 modules by adding the top-level key "type": "module"
.
Finally, we need to define an NPM automation script to run Jest by adding the following entry to the top-level scripts
dictionary:
"scripts": {
"docs": "docDir='./docs'; [[ -e \"$docDir\" ]] && rm -rf \"$docDir/\"*; npx jsdoc -c ./jsdoc.conf.json --destination \"$docDir/\" --template node_modules/docdash",
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest"
},
The command sets the environment variable NODE_OPTIONS
to the value --experimental-vm-modules
, then executes the command npx jest
.
Notice we’re not passing any arguments to the jest
command, this becomes important later.
With this definition in place we’ll be able to run Jest with the command:
npm run test
Putting it all together, this is our updated package.json
file:
{
"name": "pbs135a-joiner",
"version": "1.0.0",
"description": "An ES6 module for joining arrays in a human-friendly way for use as a worked example Programming by Stealth instalment 135",
"type": "module",
"jest": {
"testEnvironment": "jest-environment-node",
"transform": {}
},
"scripts": {
"docs": "docDir='./docs'; [[ -e \"$docDir\" ]] && rm -rf \"$docDir/\"*; npx jsdoc -c ./jsdoc.conf.json --destination \"$docDir/\" --template node_modules/docdash",
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest"
},
"keywords": [
"JavaScript",
"Jest",
"Programming",
"by",
"Stealth",
"Example"
],
"author": "Bart Busschots",
"license": "ISC",
"devDependencies": {
"docdash": "^1.2.0",
"eslint": "^8.9.0",
"eslint-config-google": "^0.14.0",
"jest": "^27.5.1",
"jsdoc": "^3.6.10"
}
}
Defining some Tests
Jest is designed to find both your config and your test suite automatically, that’s why we call it without arguments. One of the places it looks for a config is package.json
, so that’s what we put our config there. But what about finding the tests?
Jest looks for tests based on a really quite gnarly regular expression that it matches all the files contained within the Node project folder against. For our purpose, they key point is that any file named *.test.js
will get picked up by Jest, so we’ll add our tests next to the module’s source code in the src
folder in a file named joiner.test.js
.
The first thing we need to do in this file is load the code we want to test, i.e., we need to import our joiner module:
import joiner from "./joiner.mjs";
Then we’re ready to start adding tests.
Because this test file will be run by Jest, a number of functions will be injected into the global name space for our use, including the test()
function which defines, but does not execute, a test. Jest reads the test files and assembles a big data structure of tests, then it executes them, so what we’re doing is simply defining the tests.
The test()
function expects two arguments, a short label for the test as a string, and the code to execute the test as a function. Usually, the second argument is passed as an arrow function.
Within the arrow function we implement the test’s logic, making use of Jest’s matchers to implement the various checks that will make up the test.
Jest’s expect()
function is used to invoke the suite of provided matchers. The matchers have extremely obvious human-friendly names, and use the dot notation to insert modifiers like not
. Jest’s introduction to matchers is a must-read to get started with Jest. You’ll find the full list of matchers in the API docs for the .expect()
function.
Matchers are much easier to show than to describe, so let’s simply look at some example tests.
Probably the most common matcher is .toBe()
, which checks that something produces the expected value, that is to say, something is equal to what it should be. As an example, our joiner should return the string 'a, b & c'
when passed the array ['a', 'b', 'c']
. We can see this implemented within the following test:
test('Default Join', ()=>{
// typical case - joins with an & when passed a regular list of strings
expect(joiner().join(['a', 'b', 'c'])).toBe('a, b & c');
// …
});
Notice that we pass the thing we want to compare as the only argument to the expect()
function, and the value we want to compare it to as the only argument to the .toBe()
matcher function.
The next most common thing to want to check is that the correct exception is thrown for a given invalid input, this is done with the .toThrow()
matcher. Note that in this case the value to test should be a function that triggers your desired exception, and that this is usually passed as an arrow function. For example, the following check makes sure passing our joiner function something that’s not an array throws a TypeError
:
test('Default Join', ()=>{
// …
// invalid data - not an array
expect(()=>{joiner().join('pancakes')}).toThrow(TypeError);
});
Notice that the value to test is an arrow function that calls our joiner with an invalid argument (a string rather than an array), and the argument to the .toThrow()
matcher function is the kind of error we expect to be thrown.
Finally for this instalment, another common thing to want to check is whether or not something is of the expected type. For objects that means checking against the appropriate class, which you can do with the .toBeInstanceOf()
matcher function. For example, the following test makes sure that our module’s default export is a function, i.e. that it’s an instance of the built-in class Function
:
test('Module exports expected value', ()=>{
expect(joiner).toBeInstanceOf(Function);
});
For a broader context, here’s a basic test suite for our joiner, i.e. the full contents of src/joiner.test.js
:
import joiner from "./joiner.mjs";
//
// === Module-level tests ===
//
test('Module exports expected value', ()=>{
expect(joiner).toBeInstanceOf(Function);
});
//
// === Test Default Functionality ===
//
test('Default Join', ()=>{
// typical case - joins with an & when passed a regular list of strings
expect(joiner().join(['a', 'b', 'c'])).toBe('a, b & c');
// edge case - empty array
expect(joiner().join([])).toBe('');
// edge case - one string
expect(joiner().join(['a'])).toBe('a');
// edge case - two strings
expect(joiner().join(['a', 'b'])).toBe('a & b');
// invalid data - not an array
expect(()=>{joiner().join('pancakes')}).toThrow(TypeError);
});
//
// === Test the modifiers ===
//
test('ampersand modifier', ()=>{
expect(joiner().ampersand.join(['a', 'b', 'c'])).toBe('a, b & c');
});
test('and modifier', ()=>{
expect(joiner().and.join(['a', 'b', 'c'])).toBe('a, b and c');
});
test('or modifier', ()=>{
expect(joiner().or.join(['a', 'b', 'c'])).toBe('a, b or c');
});
test('single quote modifier', ()=>{
expect(joiner().quote.join(['a', 'b', 'c'])).toBe("'a', 'b' & 'c'");
});
test('double quote modifier', ()=>{
expect(joiner().doubleQuote.join(['a', 'b', 'c'])).toBe('"a", "b" & "c"');
});
test('sort modifier', ()=>{
expect(joiner().sort.join(['a', 'c', 'b'])).toBe('a, b & c');
});
Executing our Test Suite
Now that we’ve installed & configured Jest, and created a simple test suite, we’re ready to actually run our tests. To do this we simply run the command:
npm run test
This will produce quite a bit of output, but the important part is the summary at the end:
PASS src/joiner.test.js
✓ Module exports expected value (2 ms)
✓ Default Join (26 ms)
✓ ampersand modifier (1 ms)
✓ and modifier (1 ms)
✓ or modifier (2 ms)
✓ single quote modifier (2 ms)
✓ double quote modifier (2 ms)
✓ sort modifier (1 ms)
Test Suites: 1 passed, 1 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 0.284 s, estimated 1 s
Ran all test suites.
We see the result for the only test suite we defined (src/joiner.test.js
), which is a nice happy PASS
, and we see each of our tests with a pleasing tick-mark next to them, as well as how long the test took to execute. Finally, we see an overall summary. In a real-work project there would be one test suite per source file, so we’d see results for multiple suites before we see the final summary, and the summary would not be of much more value as it would be an aggregation of results.
Final Thoughts
We’ve just scratched the surface of what Jest can do. We’re not going to dive particularly deep into Jest at this stage, we’ll leave the deeper dives until we need more advanced functionality later in the series, but we do need to learn a few more basics before we move on. The next instalment will cover a few more Jest fundamentals.