Testing JavaScript means answering one question about a piece of code: does it still do what it is supposed to do? A test is a small program that runs your function with known inputs and fails loudly when the answer is not what you expected. Nothing about it needs a framework, and the first useful test suite takes about twenty minutes to write.
What is a test, in plain terms?
Three parts: a name that describes the behaviour, an input, and the answer you expect. adding two prices returns their sum in cents
is a name. 1999 and 350 are the inputs. 2349 is the expected answer. When the code returns something else, the test fails and tells you which case broke.
The point of the name is that it survives a refactor. If it said tests the add function
, it would tell you nothing once you renamed the function. If it says adds prices in cents
, a failure tells you that adding prices in cents is broken, which is exactly what you want to know at two in the morning.
How do you write your first assertion?
You compare the actual result with the expected one and print a line either way. This is the whole mechanism, and you can write it in five lines before installing anything.
let failures = 0;
function check(label, actual, expected) {
const same = JSON.stringify(actual) === JSON.stringify(expected);
if (!same) failures += 1;
console.log((same ? 'PASS ' : 'FAIL ') + label + (same ? '' : ' -> got ' + JSON.stringify(actual)));
}
const double = (n) => n * 2;
check('doubles a number', double(21), 42);
check('adds prices in cents', 1999 + 350, 2349);
check('keeps the even numbers', [1, 2, 3, 4].filter((n) => n % 2 === 0), [2, 4]);
console.log(failures === 0 ? 'all checks passed' : failures + ' check(s) failed');
The output is:
PASS doubles a number
PASS adds prices in cents
PASS keeps the even numbers
all checks passed
The failures counter is what makes this useful in practice. A test file that only prints is easy to ignore, and easy to ignore means it quietly stops matching reality. Keep the counter, print a summary line at the end, and set a failing exit status so the file can be run from a script.
Comparing with JSON.stringify works for arrays and objects as well as single values, which is why it beats the === operator for anything that is not a primitive.
How do you organise a test file?
One file per area of the code, with the tests for a module sitting next to it in a tests folder. Node ships with a small test runner built in, so a test file looks like a normal module that calls a test function for each case.
import test from 'node:test';
import assert from 'node:assert/strict';
const double = (n) => n * 2;
test('doubles a number', () => {
assert.equal(double(21), 42);
});
test('waits before it answers', async () => {
const value = await Promise.resolve('ready');
assert.equal(value, 'ready');
});
Run it with the test runner and the plain-text reporter:
node --test --test-reporter=tap
The output lists one line per test and a summary at the end:
TAP version 13
# Subtest: doubles a number
ok 1 - doubles a number
---
duration_ms: 3.8123
type: 'test'
...
# Subtest: waits before it answers
ok 2 - waits before it answers
---
duration_ms: 0.655
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 309.3598
The millisecond figures change on every run; the pass and fail counts are the part that matters. A failing test prints not ok instead of ok, shows the values that did not match, and points at the line where the comparison failed.
Two details in that file are worth copying. The test names describe behaviour rather than function names. And the second test is marked async, which is all it takes to test a function that returns a promise.
How do you test code that waits?
Mark the test function as async, await the promise inside it, and assert on the resolved value. The runner waits for your test to finish, so a promise that never settles shows up as a timeout instead of a silent pass.
async function fetchRecord(id) {
await new Promise((resolve) => setTimeout(resolve, 10));
return { id, name: 'record ' + id };
}
async function check(label, actual, expected) {
const same = JSON.stringify(actual) === JSON.stringify(expected);
console.log((same ? 'PASS ' : 'FAIL ') + label);
}
async function main() {
const record = await fetchRecord(7);
await check('the record comes back with its id', record.id, 7);
await check('the record comes back with a name', record.name, 'record 7');
}
main();
The output is:
PASS the record comes back with its id
PASS the record comes back with a name
Two things to avoid here. Never write a test that depends on the wall clock, because a slow machine will fail it for no reason; control the timing yourself, with a fake clock or an injected delay. And never assert inside a callback that the test does not await, because the test will finish before the assertion runs.
How do you test a function that reaches outside itself?
You give it the outside world as an argument. A function that sends a message should accept the sender, so the test can pass in a fake one and watch what it received.
function createNotifier(send) {
return async (message) => {
await send(message);
return 'sent: ' + message;
};
}
async function main() {
const sent = [];
const notify = createNotifier(async (message) => {
sent.push(message);
});
const result = await notify('hello');
console.log('the sender received:', JSON.stringify(sent));
console.log('the notifier reported:', result);
}
main();
The output is:
the sender received: ["hello"]
the notifier reported: sent: hello
Nothing was sent anywhere. The fake sender collected the message in an array, and the test can now assert on that array. This is the same dependency injection that keeps the files in the previous lesson apart, and it is what makes a function testable at all.
Keep the fake as simple as the contract allows. If the real sender returns a ticket number, the fake should return one too. If the fake has to grow a second method, a second argument and a configuration object, the design is asking too much of the test and the function is probably doing too much.
How do you avoid testing the wrong thing?
Test the behaviour a caller can observe, not the steps your function happens to take. A test that checks a private helper, a specific array order inside a function, or the exact wording of a log line will break every time you refactor, and the team will start ignoring the suite.
| Worth a test | Not worth a test |
|---|---|
| A rule that turns input into output | The order two independent statements run in |
| A calculation with a known answer | The exact wording of a log message |
| The awkward case: empty list, bad input, zero | A library or framework you did not write |
| A bug you fixed, so it stays fixed | The private helper a function happens to call |
One test per behaviour is a good default. If a test name needs the word and
, it is probably two tests, and the first failure will not tell you which half broke.
Testing habits worth keeping
- Name every test after the behaviour it protects, in plain words.
- Start with the awkward inputs — empty, zero, negative, wrong type — because that is where bugs live.
- Assert on what the caller sees, not on the steps the code takes internally.
- Give anything that reaches outside the program a fake you can inspect.
- Keep the suite fast enough that you run it before every commit rather than after a bug.
- When a test fails, read the message first: it should tell you what was expected and what arrived.
Try it yourself: Take the rule file from the previous lesson and write a test file for it with four cases: adding a to-do gives it the next id, adding an empty title throws, marking a missing id throws, and filtering by open returns only the unfinished ones. Run it once and confirm all four pass. Then break the rule on purpose — make the next id always 1 — and confirm that the suite fails with a message that points at the wrong id. Finally, add a fifth case for a title with spaces around it, and check that the stored title is trimmed.