JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Writing testable code with small pure modules
Split a file that mixes deciding and doing into a pure core you can check by return value and a thin shell that owns DOM, clock, and storage.
What you will learn
- Separate a pure decision core from a thin shell that owns DOM, clock, and storage
- Pass time, randomness, and ids in as arguments instead of calling globals inside
- Return new values instead of mutating module-level state shared by all callers
- Judge a module by whether you can call it with plain data and read the answer back
Understanding Writing testable code with small pure modules
What usually makes a function hard to test is not the logic inside it but everything it reaches for: document.querySelector, fetch, Date.now(), localStorage, a let declared at the top of the file. To call such a function you first have to recreate that world, and once it returns you have to go inspect the world to find out what it decided. A pure function has neither problem: the result depends only on the arguments it was handed, and running it leaves nothing changed outside itself, so checking it means comparing one value to another.
The mental model is a core wrapped in a shell. The shell reads inputs from the page, network, or storage and writes results back; it should be almost straight-line code with no interesting branches. The core is where the branching lives, the comparisons and the sorting and the awkward edge cases, and it only ever sees plain data: numbers, strings, arrays, objects. A module belongs in the core when you can load it in a bare script and it does not complain that document is undefined.
Some decisions genuinely depend on the outside world: the current date, a random pick, a fresh id, whether a request came back. Those become parameters instead of global calls, and that is the whole move. overdue(tasks, now) is an ordinary function you can ask about any day in history, while overdue(tasks) reading Date.now() is a function of the calendar. Keep the count of such parameters low; if a function needs five injected collaborators, the decision is still tangled with the doing and the split needs to move.
// streak.js -- pure core: no DOM, no clock, no storage
const DAY_MS = 86400000;
function dayNumber(isoDay) {
return Math.floor(Date.parse(isoDay + 'T00:00:00Z') / DAY_MS);
}
// today arrives as an argument; this module never asks what day it is
function streakLength(isoDays, today) {
const done = new Set(isoDays.map(dayNumber));
let cursor = dayNumber(today);
let length = 0;
while (done.has(cursor)) {
length += 1;
cursor -= 1;
}
return length;
}
// app.js -- impure shell: the only place that reads and writes the world
function showStreak(loadDays, clock, write) {
const n = streakLength(loadDays(), clock());
write(n === 0 ? 'No streak yet' : n + ' day streak');
}
const saved = ['2026-08-30', '2026-08-31', '2026-09-01', '2026-09-03'];
// the core is checked by comparing return values
console.log(streakLength(saved, '2026-09-03'));
console.log(streakLength(saved, '2026-09-01'));
// the shell is checkable too, because all three of its effects are parameters
showStreak(() => saved, () => '2026-09-01', (text) => console.log('UI:', text));
showStreak(() => [], () => '2026-09-01', (text) => console.log('UI:', text));Push the calls that touch the outside world to the edges so the decisions in the middle are just functions of their arguments.
Worked examples
Module-level state versus state as data
Shows how a variable at module scope makes a function's answer depend on what ran before it.
// hidden state at module scope: every caller shares one total
let total = 0;
function addPrice(price) {
total += price;
return total;
}
console.log(addPrice(10));
console.log(addPrice(5));
// state as data: the caller owns it, the function only computes
function withPrice(cart, price) {
return { items: cart.items.concat(price), total: cart.total + price };
}
const empty = { items: [], total: 0 };
console.log(withPrice(empty, 10).total);
console.log(withPrice(empty, 5).total);
console.log(empty.total);Example explained
Line 1addPrice(5) returns 15 because total still holds the 10 left by the previous call.
Line 2That means the expected number changes if you reorder the calls, which is exactly what breaks when tests run in a different order.
Line 3withPrice builds and returns a new object, so both calls starting from empty answer with the price alone.
Line 4empty.total is still 0 at the end, which is what lets the same fixture be reused by every case.
Randomness as a parameter
Shows a seam: the source of randomness is an argument, so the caller decides the draw.
function pickWinner(entries, random) {
return entries[Math.floor(random() * entries.length)];
}
const names = ['ana', 'bo', 'cy'];
const scripted = [0.9999, 0, 0.5];
let i = 0;
const fakeRandom = () => scripted[i++];
console.log(pickWinner(names, fakeRandom));
console.log(pickWinner(names, fakeRandom));
console.log(pickWinner(names, fakeRandom));
// the shipped call passes the real source; the function itself is unchanged
console.log(names.includes(pickWinner(names, Math.random)));Example explained
Line 1random is an ordinary argument, so a caller can supply a scripted sequence instead of hoping for one.
Line 20.9999 * 3 floors to 2, pinning down the last-index case that would take many runs to hit by chance.
Line 3The three scripted values cover the top, zero, and middle of the range and will always print the same three names.
Line 4The final line passes Math.random itself, so production runs the identical code path with no test-only branch inside.
Important notes
Date.parse('2026-09-01T00:00:00Z') is deterministic and can stay in the core; new Date() and Date.now() are the parts that read the world, so those are what move out.
Pure does not mean free of mutation. The let cursor loop above reassigns freely because nothing outside the function can observe it; what matters is what escapes.
Common mistakes
Leaving new Date() or Date.now() inside the supposedly pure function: it passes today and fails at a month boundary or in another timezone, and you can never ask it about yesterday's data.
Returning the input after mutating it with push or sort instead of returning a new array: the caller's data changes underneath it, and a case that ran earlier can change the result of a later one.
Splitting by file rather than by responsibility, so the new module imports a DOM formatting helper: you still cannot call it without document, and the split bought nothing.
Try it yourself
Change, predict, then run
Rewrite const daysLeft = d => Math.ceil((Date.parse(d) - Date.now()) / 86400000) as daysLeft(deadline, now), then log it for '2026-12-01' with now set to the day before, the deadline day itself, and a week after, without touching your system clock.
Open the JavaScript workspaceCheck your understanding
overdue(tasks) filters tasks whose due date is before new Date(). You want to pin down its behaviour for a task due yesterday. Which change makes it testable with the least added machinery?
- Freeze the time once in a module-level const NOW = new Date() and compare against that.
- Replace the global Date with a fake before the check and restore it afterwards.
- Add a now parameter, overdue(tasks, now), and pass new Date() from the calling code.
- Build the task's due date relative to new Date() so it is always one day in the past.
Show answer
The current time is an input to the decision, so making it a parameter turns a function of the calendar into a function of its arguments; the caller still passes new Date(), so shipped behaviour is identical. Replacing the global Date does work and is tempting because the function body stays untouched, but it introduces global state that must be restored, affects every other code path that reads Date, and hides the fact that the function still has an undeclared input. Freezing it in a module-level constant is worse: the hidden input remains and is now fixed at import time.