Capstone: build a small library end to end · Lesson 74 of 77 · practice

The core functions, written test first

About 100 minutes.

By the end of this lesson you can

  • Write the check for a behaviour before writing the behaviour itself.
  • Start from the empty case and let it decide the shape of the result.
  • Build totals and tallies from a list without changing the list you were given.
  • Explain why reduce with an initial value is safe on an empty array.

Writing the check first is a design decision, not a chore

Why it matters

Writing the check before the code sounds backwards, and the usual explanation - 'it catches bugs' - undersells it. The real effect is that you have to decide what the function is called, what it takes and what it hands back before you can write a single line of the body. You become the first person to use your own function, at the moment when changing your mind is still free.

That is why awkward designs get caught here. If the check is painful to write - if you need three lines of setup, or you cannot say what the result should be without a paragraph of explanation - the function is badly shaped, and you have found out before writing it rather than after building three more on top of it.

The second effect is the one people mention: a check you watched fail, and then watched pass, is wired to the behaviour. A check written afterwards has never failed, and a check that has never failed might be testing nothing at all. That is not a hypothetical - a typo in a function name gives you a check that passes for the wrong reason, and you will never notice unless you saw it red first.

Red, then green, then tidy

Explanation

The loop has three steps and they are always in this order. Write one check for one behaviour and run it: it fails, because the behaviour does not exist yet. Write the smallest code that makes it pass - genuinely the smallest, even if it is embarrassing. Then, with the check green, tidy the code up, knowing that if you break something the check will tell you.

Then repeat for the next behaviour. Each cycle is minutes, not hours. Nothing here needs a framework: your check is a function that compares two values and prints PASS or FAIL, exactly like the one you wrote in module 11.

The discipline that makes this work is doing one behaviour at a time. Writing eight checks and then eight function bodies puts you back where you started - a big pile of code with no idea which part is wrong. One check, one body, run it, next.

Start with nothing, because nothing asks the hardest question

A mental model

The best first check for any function that takes a list is the empty list. It looks like an edge case and it is really a design question: what does summarise of an empty log mean? Zero pages and zero minutes, with an empty list of titles? Or null, because there is nothing to summarise? Or an error?

Answer it now, deliberately, and the rest of the function follows from the answer. Answer it by accident - by writing the loop first and seeing what falls out - and you get whatever your code happened to do, which is usually undefined leaking into somebody else's arithmetic three functions away.

For readlog the answer is: zeros and an empty list. A log with no sessions has read zero pages, which is a true and useful statement, and it means every caller can add up results without checking for a special case first. That is the test of a good empty answer: it keeps the caller's code simple.

Totals: a running box, or reduce

Explanation

Adding up a list has two spellings and they do the same thing. The plain one: declare a total with let before the loop, walk the list with for...of, and add to it. The compact one: reduce, which takes a function saying how to combine the running total with the next item, plus the value to start from.

The value to start from is the part worth understanding. MDN's edge-case note is explicit: if an initial value is provided and the array is empty, that value is returned without ever calling your function. So [].reduce((total, value) => total + value, 0) is 0, not an error and not undefined - the empty case is handled by the 0 you wrote, which is why you should always supply it.

Leave the initial value out and reduce on an empty array throws a TypeError instead. That is the whole argument for always passing it: with the initial value your empty case is a value you chose, without it your empty case is a crash.

Which spelling to use is taste, not correctness. A running total is easier to read when you are collecting several things at once - readlog needs pages, minutes and titles from one pass - and reduce reads better when there is exactly one thing to build.

sort changes the array it was given

A common mistake

Sorting is where the polite habit of not touching your caller's data usually breaks. MDN says it plainly: sort sorts the elements in place and returns a reference to the same array, now sorted. It does not hand back a tidy copy and leave the original alone - there is no copy at all.

So titles.sort() reorders titles, and if titles came from the caller you have just rearranged their data as a side effect of asking a question. That is the kind of bug that shows up far away from its cause: some other function that assumed the original order now reports something subtly wrong, and nothing in that function is wrong.

Two safe habits. Sort only arrays you built yourself inside your own function - a list you assembled from the caller's data is yours, and sorting it is fine. Or copy first with slice() and sort the copy. Both are one word longer than the unsafe version.

There is a second surprise in the same method: the default order is not numeric. MDN states that without a compare function the elements are converted to strings and compared by UTF-16 code units, which is why [10, 9].sort() gives [10, 9] and not [9, 10]. Sorting titles is text, so the default is exactly right here - just never trust it for numbers.

The name of a check is the sentence it defends

Explanation

A check called 'test 3' tells you nothing when it goes red in a month. A check called 'an empty log reports zero pages' tells you which promise just broke, without opening the code. The label is not decoration, it is the failure message.

Write it as a claim about behaviour, in the words of the brief. 'titles holds each book once, sorted' is good. 'summarise works' is not - when it fails you learn only that something, somewhere, is wrong.

The graded exercises in this course follow the same rule: every hidden assertion has a description written to be readable on the day it fails. When one of them goes red, the description alone should tell you what to fix.

Recap

Recap

Write the check first, watch it fail, write the smallest code that makes it pass, then tidy up. The empty list is the first check because it forces you to decide what the result means when there is nothing - and zeros keep the caller's code simple. Totals come from a running variable or from reduce with an initial value, which is what makes an empty array safe. sort reorders the array it is given and returns that same array, so sort only what you built yourself, or copy first. Name every check after the promise it defends.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

Red: two checks, one of them failing on purpose

The first half of the cycle. totalPages returns 0 no matter what, which is enough to satisfy the empty case and nothing else - and the failing line tells you exactly what it got and what it wanted.

1function assertEquals(label, actual, expected) {2  const ok = JSON.stringify(actual) === JSON.stringify(expected);3  if (ok) {4    console.log("PASS - " + label);5  } else {6    console.log("FAIL - " + label + ": got " + JSON.stringify(actual) + ", wanted " + JSON.stringify(expected));7  }8}9 10function totalPages(sessions) {11  return 0;12}13 14assertEquals("no sessions is zero pages", totalPages([]), 0);15assertEquals("one session of 30 pages", totalPages([{ pages: 30 }]), 30);
  • line 6

    A failure message that names what it got and what it wanted saves you from rerunning the code by hand to find out. This is the whole reason to build the helper.

  • line 11

    Deliberately the smallest thing that could pass the first check. It is not a placeholder you forgot to fill in - it is the honest first step of the cycle.

  • line 14

    The empty case first, always. Here it passes for the wrong reason, which is fine: the second check is what forces the real implementation.

What it prints

PASS - no sessions is zero pagesFAIL - one session of 30 pages: got 0, wanted 30

Green: reduce, with the initial value doing the empty case

The smallest honest implementation. The 0 at the end is not decoration - it is what makes the empty log answer 0 instead of throwing.

1function totalPages(sessions) {2  return sessions.reduce((total, session) => total + session.pages, 0);3}4 5console.log(totalPages([]));6console.log(totalPages([{ pages: 30 }]));7console.log(totalPages([{ pages: 30 }, { pages: 12 }]));
  • line 2

    total is the running answer, session is the next item, and 0 is where the running answer starts. Leave the 0 out and an empty list throws a TypeError instead of answering.

  • line 5

    The empty case, answered by the initial value alone: MDN notes the callback is never called at all here.

What it prints

03042

Unique titles, and what sort does to the list

Building a list of each title once, then two ways of sorting it. Watch lines 10 to 14 closely: one of them leaves the original alone and one rearranges it for good.

1const sessions = [{ title: "Emma" }, { title: "Dune" }, { title: "Emma" }];2 3const titles = [];4for (const session of sessions) {5  if (!titles.includes(session.title)) {6    titles.push(session.title);7  }8}9 10console.log(titles);11console.log(titles.slice().sort());12console.log(titles);13console.log(titles.sort());14console.log(titles);
  • line 5

    includes answers 'is this already in the list', so a title read twice is only recorded once. Order so far is the order the titles first appeared.

  • line 11

    slice() with no arguments makes a copy, so the sorting happens to the copy. Line 12 proves the original is untouched.

  • line 13

    Sorting the list itself. The line prints the sorted list, and so does line 14 - because sort returned the very same array, now rearranged.

What it prints

["Emma","Dune"]["Dune","Emma"]["Emma","Dune"]["Dune","Emma"]["Dune","Emma"]

The empty case, on its own

Worth seeing by itself, because it is the reason to always pass an initial value. No callback runs on line 1 - the answer is the value you supplied.

1console.log([].reduce((total, value) => total + value, 0));2console.log([1, 2, 3].reduce((total, value) => total + value, 0));
  • line 1

    MDN's edge-case note: with an initial value and an empty array, that value is returned without calling the function even once.

What it prints

06

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

Two lines about one array. What do they print, and what does the second one tell you?

const titles = ["Emma", "Dune"];const sorted = titles.sort();console.log(titles);console.log(sorted === titles);

What does [].reduce((total, value) => total + value, 0) produce?

Why run a check before the code it checks exists?

Write it yourself

Exercise · core · 9 tests

summarise: what the whole log adds up to

Write summarise(sessions), which takes the list of canonical records from lesson 2 and returns an object with four fields: count (how many sessions), pages and minutes (the totals), and titles (each distinct title once, sorted). Start with the empty log and decide what it means before writing any loop - the tests insist on the answer the lesson argued for, because a summary of nothing is zeros, not undefined. The starter gets count right and leaves the other three as placeholders, so run it first and read the three failures as your to-do list.

What your code must do

summarise returns { count, pages, minutes, titles }. For an empty list that is 0, 0, 0 and an empty array. count is how many sessions were given, pages and minutes are the totals of those fields, and titles holds each distinct title exactly once, sorted by the default sort order (so 'Dune' before 'Emma'). summarise never changes the array it was given and never prints.

Define summarise at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Exercise · core · 8 tests

pagesByTag: a tally keyed by tag

The brief asks 'how much fiction did I read', so readlog needs a tally. Write pagesByTag(sessions) that returns a plain object whose keys are the tags that actually appear and whose values are the total pages of the sessions carrying them. A session with two tags contributes its pages to both - it is not split between them, because it is genuinely 30 pages of fiction and 30 pages of a classic. A session with no tags contributes nothing at all, and an empty log gives an object with no keys. The starter returns that empty object every time, so the empty-log tests already pass and the rest do not.

What your code must do

pagesByTag returns a plain object. Every tag appearing on at least one session is a key, and its value is the sum of the pages of the sessions carrying that tag. A session with several tags adds its full pages to each of them. A session with an empty tags list adds nothing. An empty list of sessions gives an object with no keys - never null. A tag nobody used is simply absent, so reading it gives undefined.

Define pagesByTag at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Worth remembering

  • What are the three steps of the test-first cycle, in order?

    Write one check and watch it fail. Write the smallest code that makes it pass. Tidy up with the check still green. Then the next behaviour - one at a time.

  • Why is the empty list the first check you write for a function taking a list?

    Because it is a design question, not an edge case: it makes you decide what the result means when there is nothing, and zeros keep every caller's code simple.

  • What does an initial value do for reduce on an empty array?

    It is returned as the answer, without the callback running at all. Leave it out and an empty array throws a TypeError - your empty case becomes a crash instead of a choice.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — Array.prototype.reduce()

Check there: Under Edge cases: if initialValue is provided and the array is empty, the solo value is returned without calling callbackFn; with no initialValue an empty array throws a TypeError.

MDN — Array.prototype.sort()

Check there: sort sorts in place and returns the reference to the same array; without a compare function elements are compared as strings by UTF-16 code unit order.

MDN — Object.keys()

Check there: Object.keys returns the object's own enumerable property names as an array.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

0 of 2 exercises in this lesson solved on this device

Consent version 2026-07-31.1

Cookie preferences