Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Tests, and building a program

The debugging method works, and its weakness is that it is manual: every check you made by hand evaporates the moment you close the console.

A test is that check written down so the machine performs it, on demand, forever. This lesson is about writing them, choosing which ones to write, and then using the whole course at once to build a small program from nothing. If you have arrived here directly, what is assumed is functions, arrays, objects and the previous lesson's habit of reproducing a bug as a fixed input with a known expected output.

A test is an executable claim

You do not need a framework to start. A test is a function call, an expected value, and a comparison:

function check(name, actual, expected) {
  const ok = JSON.stringify(actual) === JSON.stringify(expected);
  if (!ok) {
    console.log("FAIL " + name);
    console.log("  expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual));
  }
  return ok;
}

check("median of an even list averages the two middle values", median([1, 2, 3, 4]), 2.5);
check("median of an odd list is the middle value", median([1, 2, 3]), 2);

Twelve lines and you have a test suite. Two details in it are deliberate. It compares with JSON.stringify rather than ===, because the references lesson showed that === on two arrays with identical contents is false, and comparing structures by value is something you must do explicitly. And a failure prints both values, since "FAIL" alone tells you only what you already knew.

Silence on success is the right default. A run that prints nothing but the failures is readable at a glance, and a suite that prints a line per passing test buries the one line that matters.

A test name is a claim, so write it as one. "median works" tells a reader nothing when it fails at two in the morning; "median of an even list averages the two middle values" tells them what was expected and, very often, where to look. The name is documentation that cannot go stale, because a claim that stops being true stops passing.

What a framework adds

Every language has a test runner, bun test here, node --test in Node, and the equivalents elsewhere. None of them changes what a test is, and it is worth knowing what you are buying before you adopt one.

A runner finds the test files by a naming convention, so nobody has to maintain a list. It isolates each test, so one failure does not stop the rest and you see all ten failures at once instead of the first. It gives you an exit code, which is what lets a build refuse to ship when the suite is red, and that single fact is most of the value: a test nobody runs is a comment. And it provides assertions that print a useful difference for structures, which is the part the twelve line check above does worst.

The syntax is a test or it function taking a name and a body, with assertions inside:

import { test, expect } from "bun:test";

test("median of an even list averages the two middle values", () => {
  expect(median([1, 2, 3, 4])).toBe(2.5);
});

Two habits belong with it. Run the suite before you start work, so that when something breaks you know it was you. And watch a new test fail before writing the code that makes it pass: a test that has never been red might be asserting nothing at all, which happens more often than anybody admits.

Choosing cases that are worth writing

Testing every input is impossible, and testing three inputs picked at random is close to useless. The cases worth writing are few and predictable.

The ordinary case, one you can verify by hand. It documents what the function is for, and it is the first thing a reader looks at to find out.

The boundaries. Empty input, one element, the first and last positions, the exact value a comparison turns on. This is where the off-by-one errors of the loops lesson live, and if you write only one kind of test, write this one. A function tested at 0, 1 and 2 elements is more thoroughly tested than one checked with a hundred random arrays.

The bad input. What does it do with null, with a string where a number was expected, with a negative price? The answer can be to throw, but it has to be an answer, and writing the test is what forces you to decide.

The cases that broke before. Every bug you fix becomes a test. This is the regression test, and it is the highest value test in any codebase, because a bug that happened once in a piece of code is far more likely than average to happen there again: the code is subtle, or the specification was unclear, or the person who will refactor it next has not read this lesson. The minimised reproduction from the previous lesson is already this test; it needs only a name and an expected value.

Example. Which cases would you write for median(values)?

An odd length list, [1, 2, 3], giving 2, which is the ordinary case. An even length list, [1, 2, 3, 4], giving 2.5, which is the boundary the previous lesson found a bug in. A single element, [7], giving 7. An empty list, [], giving whatever you decided, null here. An unsorted input, [3, 1, 2], giving 2, which checks that it sorts at all. And a check that the caller's array is unchanged afterwards, which is the regression test for the mutation bug the same investigation uncovered. Six tests, each one line, covering every mistake that function has actually made.

Now you. Which cases would you write for largest(values), which returns the largest number in an array or null for an empty one?

Answer

The ordinary case, [3, 9, 2] giving 9. The empty array giving null. A single element, [5] giving 5. The largest at the first position and at the last, [9, 3, 2] and [2, 3, 9], since a wrong loop bound gets one of those wrong. All negative numbers, [-5, -2] giving -2, which catches the common bug of starting the accumulator at 0. And duplicates of the maximum, [4, 4] giving 4. The negative case is the one people miss, and it is the reason the arrays lesson started best at values[0].

What a passing suite does not mean

Be clear about the limit, because tests are oversold as often as they are neglected. Edsger Dijkstra's remark from 1969 is the standard statement and it is exact: program testing can be used to show the presence of bugs, but never to show their absence. A suite that passes means nobody has yet written a case that fails. It is evidence, not proof, and the gap is not small: a function can pass a hundred tests and be wrong on the input your users actually send.

Three consequences follow. Coverage, the percentage of lines a suite executes, measures whether a line ran, not whether anything was checked about it, so a suite can reach a hundred per cent while asserting almost nothing. A test that has never failed is unproven machinery, which is why watching it go red first matters. And a bug that reaches production is not primarily a failure of testing, it is a missing test, so the first move on receiving one is to write the case, watch it fail, then fix it.

What tests actually buy is not certainty. It is the confidence to change things. Without them every refactor is a gamble, so nothing gets cleaned up, so the code gets steadily worse; with them, a suite that stays green while you rewrite the inside of a function is what makes the rewrite affordable. That is the reason professional codebases have tests, and it is a better reason than correctness.

Building a program from nothing

Here is the whole course used at once. The task: read expense lines of the form date,category,amount, and report the total per category.

The method is decomposition. Do not write the program. Write the smallest piece that can be tested alone, test it, then the next, and assemble them at the end. Each piece is a function with the contract of the functions lesson: what goes in, what comes out, what it touches. When each piece is known to work, the assembly is short and usually right first time, and when it is not, the bug is in the assembly, which is the only part not yet tested.

Decomposing well is mostly a matter of separating the kinds of work. Parsing text into data is one job. Calculating over that data is another. Turning the answer into something printable is a third. Keep them apart and each one is pure, testable and replaceable; run them together in one function and none of the three can be checked without the other two. The six pieces below are that division applied to this task.

Six small pieces

Piece one: an amount. Money as a fraction was ruled out in the first lesson, so amounts become whole pence.

function parseAmount(text) {
  const value = Number(text);
  if (Number.isNaN(value)) throw new TypeError("not a number: " + text);
  return Math.round(value * 100);
}

The Math.round is not decoration. Number("4.35") * 100 evaluates to 434.99999999999994, so without it every amount ending in certain digits becomes a fraction of a penny and every total downstream inherits the error. This rounding is exact for two decimal places at ordinary amounts, and it is not a general decimal parser: Math.round(Number("1.005") * 100) gives 100 rather than 101, because 1.005 is stored slightly below its decimal value. Real accounting systems use a decimal library for exactly this reason, and knowing where your shortcut stops being true is the point.

Piece two: a line. It validates the shape and throws at the edge, as the errors lesson argued.

function parseLine(line) {
  const parts = line.split(",");
  if (parts.length !== 3) throw new Error("expected 3 fields, got " + parts.length);
  return { date: parts[0], category: parts[1], pence: parseAmount(parts[2]) };
}

Piece three: the whole text, as a pipeline from the map and filter lesson. Trimming before filtering is what makes stray whitespace harmless: a line of spaces is dropped rather than parsed, and a leading space cannot end up inside a date.

function parseAll(text) {
  return text
    .split("\n")
    .map(line => line.trim())
    .filter(line => line !== "")
    .map(parseLine);
}

Piece four: the totals, which is the grouping pattern from the objects lesson.

function totalsByCategory(entries) {
  const totals = {};
  for (const e of entries) {
    totals[e.category] = (totals[e.category] ?? 0) + e.pence;
  }
  return totals;
}

Pieces five and six: the output. Formatting is separated from calculating, because the calculating half is then pure and testable and the formatting half is one line.

function formatPence(pence) {
  return "£" + (pence / 100).toFixed(2);
}
function report(totals) {
  return Object.keys(totals)
    .sort()
    .map(k => k + ": " + formatPence(totals[k]))
    .join("\n");
}

Example. What do parseAmount("12,50") and parseLine("2024-01-03,groceries") do, and is that the right behaviour?

parseAmount("12,50") throws TypeError: not a number: 12,50, because Number("12,50") is NaN and the guard catches it. parseLine("2024-01-03,groceries") throws Error: expected 3 fields, got 2. Both are right: these are malformed inputs arriving at the edge of the program, and the errors lesson's argument applies in full. Returning null or 0 instead would let one bad line quietly reduce a category total, with nothing anywhere to say which line did it. Each message names the offending text, which is the whole diagnosis when a file of ten thousand lines has one bad row in it.

Now you. parseAll trims each line before filtering out the blank ones. What breaks if the trim is removed, and which single test would catch it?

Answer

Two things, and neither is the case people expect. A plain trailing newline is still fine, because the last element really is the empty string and the filter drops it exactly. But a line containing only spaces is not the empty string, so it survives the filter, reaches parseLine, and throws expected 3 fields, got 1, killing the import on a file that is perfectly valid. And a line with a leading space parses into a date of " 2024-01-03", which is silently wrong and will not match anything later. The test for the first is check("a line of spaces is ignored", parseAll("2024-01-03,a,1.00\n \n").length, 1), which is a boundary case in the exact sense of the earlier section: it differs from the ordinary input by characters nobody can see.

The tests, and the assembly

Now the tests, written alongside the pieces rather than afterwards. Each is an ordinary case or a boundary:

check("parseAmount rounds a value that is not exact in binary", parseAmount("4.35"), 435);
check("parseLine splits three fields", parseLine("2024-01-03,groceries,42.50"),
      { date: "2024-01-03", category: "groceries", pence: 4250 });
check("parseAll ignores blank lines", parseAll(text).length, 4);
check("parseAll of empty text gives no entries", parseAll(""), []);
check("totals sum by category", totalsByCategory(parseAll(text)),
      { groceries: 5975, transport: 435, rent: 75000 });
check("totals of nothing is an empty object", totalsByCategory([]), {});
check("formatPence pads to two decimals", formatPence(435), "£4.35");
check("formatPence handles zero", formatPence(0), "£0.00");

And the assembly, which is one line because everything under it is already known to work:

const text = `
2024-01-03,groceries,42.50
2024-01-05,transport,4.35
2024-01-09,groceries,17.25
2024-02-01,rent,750.00
`;
console.log(report(totalsByCategory(parseAll(text))));
// groceries: £59.75
// rent: £750.00
// transport: £4.35

Check it by hand, which is the habit this whole course has been pressing: groceries is 4250+1725=5975 pence, or £59.75; rent is 75000 pence, or £750.00; transport is the single 435. The categories come out alphabetically because report sorts the keys, since object key order is not something to rely on for a printed report.

Example. A new requirement arrives: skip lines beginning with #, so the file can carry comments. Where does the change go, and which test do you write first?

In parseAll, as one more filter before the map(parseLine). Write the test first: check("comment lines are skipped", parseAll("# note\n2024-01-03,a,1.00").length, 1). It fails, because parseLine currently throws on the comment line, and that failure confirms the test is actually exercising the new behaviour. Then add .filter(line => !line.startsWith("#")) and it passes. The other seven tests still passing is the evidence that nothing else broke, and that evidence is the whole point of having written them.

Now you. A bug is reported: a line with a trailing comma, 2024-01-03,groceries,42.50,, is accepted and produces a wrong amount. What test do you write, and where is the fix?

Answer

The test is the reproduction, turned into a claim: the call should throw rather than return a value. split gives four parts for that line, so parseLine already throws expected 3 fields, got 4, which means the report is either about a different input or about code that was changed since. That is worth checking before writing any fix, because a bug report that cannot be reproduced is the first step of the previous lesson failing, and fixing code that is already correct is how new bugs are introduced.

What you can do now, and what comes next

Take stock of the chain. A program computes with values whose types decide what its operators mean. It varies its behaviour by asking questions of those values. It repeats work with loops that are correct because of an invariant and finite because of a variant. It is organised into functions with contracts, over data held in arrays and objects, with an honest account of what a name reaches and what a copy copies. And when it goes wrong, which it does, there is a method for finding out why and a way to write the answer down so the machine remembers it.

That is enough to write real programs, and there is nothing further you need before starting. The best next step is not another lesson: it is to pick something you actually want to exist, small enough to finish, and build it the way this lesson did, in tested pieces.

Two directions open from here. The first is correctness at scale, which is the province of types, code review and the design of larger systems. The second is cost, which this course has been almost silent about: every program here has been correct, and none of them were asked whether they were fast. Two correct programs for the same task can differ by a factor of a million on the same input, and knowing which structure to reach for and how to justify the choice is a subject of its own. That subject is Algorithms and Data Structures, and it assumes exactly what you now have.