Module 10 · Capstone - Real-World Projects ⏱ 19 min

Project Polish & Reflection

By the end of this lesson you will be able to:
  • Refactor working code into pure, testable functions without changing its behaviour
  • Write a tiny assertion helper and test runner that prove code still works
  • Document decisions with clear names and intent comments, and plan your next steps

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
The polish pipeline: working code becomes maintainable code by extracting, testing, then documenting.

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 pure function maps input to output deterministically and is easy to test; a side-effecting one is not.

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.

assertEqual throws on mismatch and returns true on a match. Wrap calls in try/catch to report.
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.

A pure total reads the array without destroying it, so the caller's cart is left intact.
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
A test runner runs each test, catches any throw, and records { name, passed }.
A minimal test runner: run each fn, catch per test so one failure does not stop the rest.
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);
Exercise

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
}
Exercise

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 }])));
Exercise

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;
}
Exercise

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 }, ...]
}
Exercise

Which change makes a function easiest to unit-test?

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; assertEqual is 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.

Checkpoint quiz

You refactor a function and one of its tests now fails. What does that tell you?

Why is a function that mutates the array it receives hard to test?

Go deeper — technical resources