JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Testing JavaScript with a modern test runner
After this you can write tests as plain functions, pick the assertion that gives a useful failure message, and keep async tests from passing by accident.
What you will learn
- Write tests as named functions where a thrown assertion is the only failure signal
- Pick strictEqual or deepStrictEqual by whether you compare identity or structure
- Await every promise inside an async test so the runner cannot end it too early
- Prove a new test can fail by breaking the source before trusting a green run
Understanding Testing JavaScript with a modern test runner
A test runner is far less magic than its output suggests: it collects named callbacks, calls each one inside a try/catch, and classifies the test by whether an exception escaped. That is why assertions throw instead of returning false — the throw both records the failure and abandons the rest of the test body, so you are not handed a cascade of follow-on errors caused by the first wrong value. The same mechanism explains a surprising fact: a test whose assertions never execute passes, because the runner has no way to know a check was supposed to happen.
The failure report can only contain what the assertion knew at the moment it threw, so which assertion you reach for decides how long debugging takes. assert.ok(total === 5) can say nothing more than that an expression was false, while assert.strictEqual(total, 5) prints both sides. Reach for deepStrictEqual when you care about contents rather than identity: === on two freshly built arrays compares references, fails, and then prints two values that look identical on screen.
Asynchronous tests need an explicit end signal, and in a modern runner that signal is the promise your test function returns. Declare the test async and await everything inside it; forget, and the function returns undefined, the runner marks the test finished and green, and the assertion later throws into an empty room, usually appearing as a stray error after the summary. Once your tests live in files like slugify.test.js, node --test discovers them and runs each file in its own process so globals cannot leak between files, and node --test --watch reruns only what you touched.
// Every runner - node:test, Vitest, Jest - implements this contract:
// a named function that returns normally passes, one that throws fails.
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
function equal(actual, expected) {
if (actual !== expected) {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function slugify(title) {
return title.trim().toLowerCase().replace(/\s+/g, '-');
}
test('lowercases and hyphenates words', () => {
equal(slugify('Hello World'), 'hello-world');
});
test('collapses runs of whitespace', () => {
equal(slugify(' two words '), 'two-words');
});
test('drops punctuation', () => {
equal(slugify('Why JS?'), 'why-js');
});
let failed = 0;
for (const { name, fn } of tests) {
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
failed += 1;
console.log(`FAIL ${name}`);
console.log(` ${err.message}`);
}
}
console.log(`${tests.length - failed} passed, ${failed} failed`);A test runner is just a loop that calls your named functions in a try/catch, so a thrown assertion is the only thing that can mark a test as failed.
Worked examples
Reference equality versus deep equality
Shows why a correct function fails a strict assertion on arrays, and what the failure message looks like when it happens.
const strictEqual = (a, b) => {
if (a !== b) throw new Error(`strictEqual: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`);
};
const deepEqual = (a, b) => {
if (JSON.stringify(a) !== JSON.stringify(b)) {
throw new Error(`deepEqual: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
}
};
const uniq = (list) => [...new Set(list)];
const test = (name, fn) => {
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}: ${err.message}`);
}
};
test('strictEqual compares the two array references', () => {
strictEqual(uniq([1, 1, 2]), [1, 2]);
});
test('deepEqual compares the contents', () => {
deepEqual(uniq([1, 1, 2]), [1, 2]);
});Example explained
Line 1uniq builds a brand-new array on every call, so !== is true even though every element matches.
Line 2The strict failure prints two identical-looking values; that symptom is the signal you needed a structural comparison.
Line 3JSON.stringify stands in for deepStrictEqual here, but the real one also compares prototypes and values JSON silently drops, such as undefined.
Why an async test needs await
The same wrong expectation fails when the promise is awaited and passes when it is not.
const equal = (actual, expected) => {
if (actual !== expected) throw new Error(`expected ${expected}, got ${actual}`);
};
const test = async (name, fn) => {
try {
await fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}: ${err.message}`);
}
};
const loadTotal = () => new Promise((resolve) => setTimeout(() => resolve(2 + 3), 5));
(async () => {
await test('awaited: the assertion belongs to the test', async () => {
equal(await loadTotal(), 6);
});
await test('not awaited: the test ends before the check', () => {
loadTotal()
.then((total) => equal(total, 6))
.catch((err) => console.log(`late error, no test left to fail: ${err.message}`));
});
console.log('run finished');
})();Example explained
Line 1await fn() is the whole mechanism: an async test returns a promise, and the rejection carries the assertion error into the runner's catch.
Line 2The second test body returns undefined, so the runner decides the test is finished five milliseconds before the timer fires.
Line 3The error still happens, but it lands after 'run finished' — a green test plus a detached error afterwards is the fingerprint of a missing await.
Asserting that a call throws
Demonstrates the guard clause that keeps an error test from passing when the function stops throwing.
function parsePort(input) {
const port = Number(input);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new RangeError(`bad port: ${input}`);
}
return port;
}
function assertThrows(fn, message) {
let error;
try {
fn();
} catch (err) {
error = err;
}
if (!error) throw new Error('expected a throw, but the call returned');
if (error.message !== message) throw new Error(`wrong error: ${error.message}`);
}
const test = (name, fn) => {
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}: ${err.message}`);
}
};
test('rejects a port above the range', () => {
assertThrows(() => parsePort('70000'), 'bad port: 70000');
});
test('rejects text that is not a number', () => {
assertThrows(() => parsePort('http'), 'bad port: http');
});
test('a valid port is not supposed to throw', () => {
assertThrows(() => parsePort('8080'), 'bad port: 8080');
});Example explained
Line 1The call is wrapped in an arrow function so assertThrows decides when it runs; passing parsePort('70000') directly would throw before the assertion existed.
Line 2if (!error) throw is the guard that turns a silent non-throw into a failure, and it is exactly the line hand-written try/catch tests omit.
Line 3Comparing error.message means a RangeError raised for some other reason cannot make the test green.
Important notes
node:test ships with modern Node and needs no install; Vitest and Jest wrap the same throw-to-fail contract in describe/it, so the mental model transfers unchanged.
node --test only picks up files it recognises as tests, such as sum.test.js, sum-test.js, or anything inside a test/ directory, so a perfectly written file with the wrong name never runs at all.
Common mistakes
Comparing arrays or objects with assert.strictEqual: it checks references, so a correct function fails and the message shows two values that look identical, sending you hunting for a bug that does not exist.
Omitting await or return in an async test: nothing throws before the function returns, the runner prints a pass, and the real assertion error shows up later looking unrelated to any test.
Hand-rolling an error test as try/catch with the assertion inside catch and no 'expected a throw' guard: the day the function stops throwing, the catch block never runs and the test stays green forever.
Try it yourself
Change, predict, then run
In a browser editor, implement test(name, fn) plus an equal assertion that throws, then use them to check a titleCase(str) function against 'hello world', 'a', and the empty string. Write the three tests before the implementation and keep editing until the summary line reads 3 passed, 0 failed.
Open the JavaScript workspaceCheck your understanding
A test function is not declared async: it calls loadTotal() and puts the assertion inside a .then callback. That assertion would fail. What does the runner report for the test, and why?
- It fails with a timeout, because the runner waits for the pending promise and never receives it
- It fails, because the runner counts assertions and notices that none ran
- It passes: the function returned undefined, so the runner recorded a finished, non-throwing test before .then ever ran
- It passes, then the runner reruns it automatically once the rejection surfaces
Show answer
The pass/fail decision is made when the test function returns, or when a promise it returned settles. Returning undefined ends the test immediately, long before the callback throws. The timeout answer is tempting, but a runner can only time out on a promise you actually handed it; with no await and no return there is nothing to wait on, so the later error arrives detached from any test.