Testing and Quality

Node ships a test runner; the browser gives you assertions by example. This lesson builds the no-framework quality loop: test, lint, format.

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.