Testing and Quality
Why test
The refund on every refactor
Lesson 14 split failures into thrown errors and silent logic errors. Tests close the logic-error gap: they are executable expectations that run in milliseconds. Their payoff is not the moment they pass — it is the next refactor, when they tell you within seconds whether you changed behavior. That safety net is what makes fast iteration possible at all.
node:test — the built-in runner
No install, no config
// savings.test.js — run with: node --test
// (or plain `node savings.test.js` — the module runs either way)
const test = require("node:test");
const assert = require("node:assert");
function withdraw(balance, amount) {
if (amount > balance) throw new RangeError("insufficient funds");
return balance - amount;
}
test("withdraw reduces the balance", () => {
assert.strictEqual(withdraw(100, 30), 70); // strictEqual: === with type check
});
test("withdraw rejects overdrafts", () => {
assert.throws(() => withdraw(10, 50), RangeError); // lesson 14's error types, tested
});
// Expected output (abbreviated):
// ✔ withdraw reduces the balance (0.6ms)
// ✔ withdraw rejects overdrafts (0.3ms)
// ▶ 2 tests passed
Every assertion failure prints both values with types — most test failures are type or shape mismatches, and the diff shows them immediately.
Arrange–Act–Assert
Three steps per test, visible
test("transfer moves money between accounts", () => {
// ARRANGE — objects under test in a known state
const from = { balance: 100 };
const to = { balance: 0 };
// ACT — exactly one behavior
transfer(from, to, 40);
// ASSERT — the observable outcome, not the implementation
assert.strictEqual(from.balance, 60);
assert.strictEqual(to.balance, 40);
});
If Arrange grows longer than Act and Assert together, the code under test is asking for a constructor or a fixture helper — listen to that signal.
Testing async code and errors
An async test awaits — the runner handles the promise
test("loadUser returns the parsed user", async () => {
const user = await loadUser(1); // return/await the promise — an unawaited call
assert.strictEqual(user.id, 1); // would pass vacuously before the data arrives
});
Contraexample: an async test without await on the call under
test passes in 0 ms while the work is still running. If a test always passes in 0 ms,
suspect it is not awaiting anything.
Asserting rejections, not just throws
test("withdraw rejects overdrafts", async () => {
// assert.rejects: for PROMISES that reject (async throws surface as rejections)
await assert.rejects(
() => withdrawAsync(10, 50), // a function — the runner calls it and awaits
(error) => error instanceof RangeError && /insufficient/.test(error.message),
);
});
Assert the message when the message is a contract — users saw it in a red banner (lesson 14); regressions there are user-visible.
Lint and format: the automated reviewers
$ npx eslint . # unused vars, unreachable code, == , undeclared names
$ npx prettier -w . # formatting — ended every "tabs vs spaces" debate permanently
A linter is a style rule you never argue about again: it catches the pitfalls from this
phase's sibling lesson (unused variables, accidental globals, floating promises with the
right plugin) before any human reads them. Format with a tool, lint in CI, test on
node --test — the whole loop ships with zero dependencies beyond dev tooling.
Testing in the loop
The inner loop: watch mode
$ node --test --watch
# reruns on every save — the test suite becomes a REPL-level feedback loop
The workflow this track practices: write one failing test, make it pass, refactor with the
green light on. A full runnable suite — nested tests, hooks, async cases — ships as demo
demo/practice/32_node_test_demo.js; run it with
node --test roadmap/javascript/demo/practice/32_node_test_demo.js.
Practice: test first, once
The task
Run the demo suite above, then extend it: add a deposit(account, amount) that
rejects negative amounts, and write its three tests BEFORE the function —
happy path, rejection type, rejection message. Notice how much faster the implementation
is when the contract is already written down.
The checklist
- Your tests follow Arrange–Act–Assert, one behavior each.
- You assert error types — and messages when they are contracts.
- You await every promise in async tests, and distrust 0 ms passes.
- You run lint and format before any human review.