Builtin Node.js Testing
As of Node.js 18, which became stable in April 2022, you can test your backend code without installing any dependencies.
This works because of the new node:test
package, along with the existing node:assert
package—which is from the very first releases of Node.
Here's the tl;dr. First, write a script like this, naming it e.g., "test.js":
import test from 'node:test';
import assert from 'node:assert';
test('check something' () => {
// check that 1+1 equals 2
assert.equal(1 + 1, 2);
});
And then run "node test.js". It'll spit out some information on your tests: hooray! 🎊
If your tests have failed, Node will let you know in the output—and the status code will be non-zero, which'll count as a failure for other tools like automated test runners.
The Test Flag
While it's cute to run a single file with Node and have it spit out its results, most of the time, you'll want to use the "node --test" flag. When used without any further arguments, this will automatically find JS files inside folders named "test", or files named "test-foo.js" and "foo-test.js", and run them. For many projects, this will be enough and just work. 👍
However, you can also tell Node just to look in certain folders or run files explicitly as tests:
-
To search some folders (with the same rules as above), use "node --test src/tests/".
-
To always run certain files as tests, use "node --test whatever-tests/*.js". Node normally can't run more than one file at once, so the
--test
flag helps us out here.
Some caveats:
-
If "node --test" can't find any tests, it'll still succeed—this is dangerous, as you might think your tests are passing when they're not even being run! So be sure to run the command yourself, before wiring it up to some CI process, to make sure your tests are being run.
-
The "node --test" command was only added in Node 18.1, which is available in most places now, but make sure you're not stuck on 18.0.
Automation
You've found a way to run your tests, but it could be easier. So add this to your "package.json" file:
{
"name": "your-package-name",
"scripts": {
"test": "node --test src/test"
},
"license": "Apache-2.0",
"type": "module"
}
Now you can just run npm test
or yarn test
.
GitHub even has a great article on how to use this command to add a CI action to your project!
More notes on writing tests
You now know enough to be dangerous (well, safe, because you're writing tests. Tests are cool. Be cool! 😎) Read on for some more thoughts.
Asynchronous and grouped tests
You can make any test async
, and Node will happily wait for its result.
For example:
test('check async code', async () => {
const result = await someLongRunningTask();
assert.strictDeepEqual(result, { status: 'ok' }, 'Status is OK');
});
You can also group tests by calling calling t.test
within another test.
The t
here is the context object passed to a test, which you might not otherwise need, so be sure to include it in this case:
test('groups other tests', (t) => {
t.test('subtest #1', () => {
assert.equal(100, 25 + 75);
});
t.test('subtest #2', () => {
assert.equal(4, 2 + 2);
});
});
(This is different from my preferred test runner Ava, where all the assert methods are on the context.)
Note that if your grouped test is async
, then the parent test should await
its result—you may need to make it async
all the way down:
test('groups other tests', async (t) => {
await t.test('subtest #1', () => {
const r = await longTask();
assert.equal(r, 'longtask is long');
});
});
Assertions
In tests, you can literally just throw
to cause a failure.
You don't strictly need the assert
library.
However, Node.js has a great built-in assertion library, which I've been using above. It typically works by you passing the actual and then expected values to compare for equality. (I remember the order of these as A comes before E in the alphabet.)
So I've used assert.equal
and friends in the above examples, but there's actually a few more helpers I'd like to call out:
assert.throws/rejects
: confirm that something fails
// throws is for for non-async methods
test('something', () => {
assert.throws(() => {
JSON.parse("this is not JSON and should fail");
});
});
// rejects is for async methods
test('something', async () => {
assert.rejects(async () => {
JSON.parse("this is not JSON and should fail");
});
});
assert.deepStrictEqual
: confirm that a large object matches
test('something', () => {
const result = await something();
assert.deepStrictEqual(result, {
value: {
x: 1,
y: 'foo',
},
});
});
assert.CallTracker
: check that something was called 1-n times
This needs a bit of explanation.
When writing a test, you might want to make sure that a particular callback is invoked at all, or a number of times.
You can wrap a callback with CallTracker.calls
, providing a number of times it should be called—it must be greater than zero.
You later then call CallTracker.verify
, which fails if the condition was not met.
Here's the example:
test('calls', (t) => {
const actualCallback = () => {
// could do something
};
const tr = new assert.CallTracker();
const trackedCallback = tr.calls(actualCallback, 1); // should be called once
doOperationWithCallback(trackedCallback);
tr.verify();
});
This doesn't support 0 times.
If you want that, just write a callback that itself throws an Error
.
An aside on CommonJS
If you're not using type: "module"
, then you'll have to require()
the relevant modules instead:
const test = require('node:test');
const assert = require('node:test');
test('check something' () => {
// check that 1+1 equals 2
assert.equal(1 + 1, 2);
});
Nothing else needs to change. But it's 2022. Have you considered swapping to ESM?
Build Systems
Does your code need to be built before test? No worries, just add a build step before the test runner:
$ esbuild --bundle --format=esm src/test/*.js --outdir test-dist/
$ node --test test-dist/*.js
Or in your "package.json" file:
{
"scripts": {
"test": "esbuild --bundle --format=esm src/test/*.js --outdir test-dist/ && node --test test-dist/*.js"
}
}
We're using the --test
flag because Node by default cannot run multiple files, so even though you know they'll all be matched by the "test-dist/*.js" glob, Node without that flag will just run the 1st file.
So be sure to remember it.
Done
That's all! I hope you find this guide handy. 👋