Code that works and code that is professional are not the same thing. Through nine modules you have written functions that compute the right answer. That is the hard part, and it matters. But code lives a long time: other people read it, you come back to it months later, and every bug fix or new feature has to fit beside it. Polish is the work that turns a working program into one you can maintain, trust, and extend without fear.
This final capstone is about that work. You will take code that already runs and make it cleaner, give it tests that prove it still runs, and write down the decisions behind it. None of this changes what the program outputs today — it changes how confidently you can change it tomorrow. The habits here are exactly what separate a hobby project from a codebase a team can ship.
flowchart LR W["working code"] --> E["extract pure functions"] E --> T["add tests"] T --> D["document decisions"] style W fill:#e0900b,color:#fff style D fill:#1e7f3d,color:#fff
Make it pure, then test it
The single biggest lever on testability is purity. A pure function takes inputs, returns an output, and touches nothing else — no console.log buried inside, no mutating the array you were handed. Give a pure function the same arguments and it returns the same answer, every time. That makes it trivial to test: call it, compare the result, done.
A function that mutates its input is the opposite. It appears to work — it returns the right total — but it empties the array you passed in as a side effect, so the next line that reads that array sees nothing. The fix is to read without destroying: loop over the input instead of popping it apart. We will repair exactly that defect below.
flowchart TD P["pure function<br/>input to output"] -->|deterministic| TEST["easy to test"] S["side-effecting<br/>logs or mutates"] -->|unpredictable| HARD["hard to test"] style TEST fill:#1e7f3d,color:#fff style HARD fill:#b91c1c,color:#fff
A tiny assertion helper
A test is just a function that throws when the answer is wrong. You do not need a framework to write one. The smallest useful tool is an assertion: assertEqual(actual, expected) compares two values and throws if they differ. Build it once and every later check becomes a one-liner.
This is the seed of every test runner — Jest, Mocha, Vitest, all of them. They add reporting, parallelism, and watch modes, but the core is the same throw-on-mismatch you can write in three lines. Knowing what a test is removes the mystery from the tools that automate them.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error("not equal");
}
return true;
}
assertEqual(2 + 2, 4);
console.log("addition ok");
try {
assertEqual(2 + 2, 5);
} catch (e) {
console.log("caught: " + e.message);
}
Refactor without changing behaviour
Refactoring means improving the shape of code without changing what it does. Extract a tangled loop into a named function so the name explains the intent. Replace a manual accumulator with reduce so the pattern is recognisable. Pull the side effect — the console.log, the mutation — out to the edges, leaving a pure core you can test.
The discipline is that the tests must pass both before and after. If your refactor breaks a test, you have changed behaviour, and that is a different task. Keep the safety net of tests in place first; then move the furniture.
function total(prices) {
let sum = 0;
for (const p of prices) {
sum += p;
}
return sum;
}
const cart = [10, 20, 30];
console.log(total(cart));
console.log(cart.length);
Test the edges, not just the middle
A first test usually checks the obvious input, the one that clearly works. That catches typos, but the real defects live at the boundaries: an empty array, a single element, a zero, a negative number, the exact point where one branch hands off to another. Write a test for each edge your function claims to handle, because that is where an off-by-one or a missing guard quietly takes hold.
The empty array is the edge case for total. A version that divided its sum by the length would return NaN on no inputs instead of the 0 a caller expects, which is exactly why the exercise below calls total([]) alongside the normal case. When you build a function, ask which input would make the obvious logic stumble, and turn that input into a test before a user finds it for you.
flowchart TD
T["test: name + fn"] --> R{"fn throws?"}
R -- No --> OK["passed: true"]
R -- Yes --> BAD["passed: false"]
style OK fill:#1e7f3d,color:#fff
style BAD fill:#b91c1c,color:#fff
function runTests(tests) {
return tests.map(({ name, fn }) => {
try {
fn();
return { name, passed: true };
} catch (e) {
return { name, passed: false };
}
});
}
const results = runTests([
{ name: "math", fn: () => { if (1 + 1 !== 2) throw new Error(); } },
{ name: "broken", fn: () => { throw new Error("boom"); } }
]);
console.log(results);
Write assertEqual(actual, expected) that returns true when the two values are equal (compare with ===), and throws new Error("not equal") when they differ.
function assertEqual(actual, expected) {
// return true if equal, throw new Error("not equal") otherwise
}
Compare with === and branch on the result.
Throw new Error("not equal") on a mismatch, and return true on a match.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error("not equal");
}
return true;
}
What does this print? A pure summarize function returns an object built only from its argument, so it is easy to predict. console.log prints the compact JSON.
function summarize(items) {
const total = items.reduce((s, i) => s + i.qty, 0);
return { count: items.length, total };
}
console.log(JSON.stringify(summarize([{ qty: 2 }, { qty: 3 }])));
reduce adds the qty values: 2 + 3 = 5.
The array has two items, so count is 2.
JSON.stringify of { count: 2, total: 5 } is compact, with no spaces.
This total(prices) returns the right sum but destroys its input — it pops every element off the array, so the caller's array is left empty afterwards. Rewrite it to compute the same total without mutating prices.
function total(prices) {
let sum = 0;
while (prices.length) {
sum += prices.pop();
}
return sum;
}
Loop over prices with for...of and add each value, instead of popping.
Reading the values leaves the original array untouched.
function total(prices) {
let sum = 0;
for (const p of prices) {
sum += p;
}
return sum;
}
Transfer task. Write runTests(tests) where tests is an array of { name, fn }. Each fn is a test that throws if it fails. Return an array of { name, passed } — passed is true if fn ran without throwing, false otherwise. Catch errors per test so one failure does not stop the rest.
function runTests(tests) {
// return [{ name, passed }, ...]
}
Use tests.map, destructuring { name, fn } from each entry.
Wrap fn() in try/catch: return { name, passed: true } on success and { name, passed: false } in the catch.
function runTests(tests) {
return tests.map(({ name, fn }) => {
try {
fn();
return { name, passed: true };
} catch (e) {
return { name, passed: false };
}
});
}
Which change makes a function easiest to unit-test?
A function that returns a value computed purely from its arguments has no hidden state or side effects, so a test can call it and compare the result directly. Printing hides the answer inside a side effect the test cannot easily read.
Reflect, and look ahead
You have come from let and const to Promises, module graphs, and now an API client. Pause and notice the shape of the journey: each lesson added one durable idea, and each project wove several together. The same rhythm carries you forward from here.
Where next? Pick a real project — something you actually want — and build it end to end with a browser and a server. Learn the DOM and events, then fetch and the request-response cycle, then a framework like React once the fundamentals feel routine. Return to these capstones whenever a concept feels shaky; they are small enough to re-read and concrete enough to stick. The goal was never to memorise syntax — it was to think clearly about problems, and you can do that now.
Recap
- Polish is maintainability work: refactoring, testing, and documenting code that already runs.
- Prefer pure functions — same inputs, same output, no side effects — because they are trivial to test.
- A test is a function that throws on mismatch;
assertEqualis the seed of every test runner. - Refactor changes shape, not behaviour; keep tests green before and after.
- Names describe what; comments describe why. A good name beats a restating comment.
That closes the JavaScript course. The fundamentals are yours; the rest is practice on problems you care about.