Compilation asks whether the program satisfies static rules. A test checks an observable behavior for selected inputs. Neither proves every possible outcome. Useful tests target boundaries, failures and invariants, not the spelling of an implementation's helper functions.
Node includes a test runner and strict assertion library. Keep browser-dependent code separate from pure calculations so that core behavior can run in a fast Node test. Use a browser test for focus, form submission and visible DOM updates; mocking a document object is not equivalent to testing a real browser.
A complete testable module
export function totalMinutes(values) {
if (!Array.isArray(values) || !values.every(v => Number.isInteger(v) && v >= 0)) {
throw new Error("Invalid duration list");
}
return values.reduce((sum, value) => sum + value, 0);
}
export async function loadTotal(read) {
const values = await read();
return totalMinutes(values);
}
import test from "node:test";
import assert from "node:assert/strict";
import { totalMinutes, loadTotal } from "./study.mjs";
test("totals values without changing the input", () => {
const values = [25, 15];
assert.equal(totalMinutes(values), 40);
assert.deepEqual(values, [25, 15]);
});
test("defines empty input and rejects bad durations", () => {
assert.equal(totalMinutes([]), 0);
for (const values of [[-1], [1.5], ["25"]]) {
assert.throws(() => totalMinutes(values), /Invalid/);
}
});
test("handles async success and propagates failure", async () => {
assert.equal(await loadTotal(async () => [10, 20]), 30);
await assert.rejects(loadTotal(async () => { throw new Error("offline"); }), /offline/);
});
Run node --test study.test.mjs. All three tests should pass. The async test awaits both the result and the rejection assertion. Forgetting to return or await a promise can allow a test to finish before the important work occurs. Do not add arbitrary sleeps when an observable promise or event can tell you completion happened.
Test boundaries, not only the happy path
For fetch-based code, inject a fake at the network boundary and test success, HTTP errors, malformed data and cancellation separately. A live external service makes a unit test depend on availability, credentials and changing data. Keep a separate integration check when you need to exercise the real service. Avoid sharing mutable fixtures between tests and make time or random IDs injectable when exact outcomes matter.
TypeScript can check source before these runtime tests. Its type guarantees are not a reason to skip negative tests at an external boundary. Expected compiler errors can be checked with @ts-expect-error in dedicated type tests; unlike a blanket suppression, it fails if the expected error disappears. Keep intentionally invalid examples separate from executable application code.
Debug from evidence
Reproduce the smallest failing case, read the stack, inspect the actual input, and place a breakpoint before the first incorrect value. Browser developer tools and Node's inspector can pause code and examine scope. Source maps connect emitted JavaScript locations to TypeScript sources; enable them when debugging a compiled project. Never log access tokens or full private records as a shortcut.
Exercise: add a test showing that [0, 10] is accepted, and one showing that a failure from the injected reader is not converted into zero. Deliberately change the implementation to swallow the failure and confirm that the test detects it.
Reference: Node test runner.
Previous: Type Design, Utility Types and Modules. Next: Capstone: Build a Typed Study Planner.

