Find the off-by-one
The bug from the whole chapter, now yours to fix. average([4, 5, 6]) should be 5 and average([7]) should be 7. Use Step through and watch i before you change anything.
LEARN TO PROGRAM · CHAPTER 08 · INTERMEDIATE
Every programmer writes bugs. The good ones aren't the ones who never make mistakes; they are the ones who find them quickly, and who build nets that catch them before anyone else does.
This chapter follows one small bug through a lab of twelve stations: what errors are, how a program reports them, how to hunt one down on purpose, and how tests stop it coming back. Scroll, and the 3D follows.
01 · THREE KINDS OF ERROR
Errors come in three kinds, and they show up at different times. On the stage, three machines: one that never starts, one that starts and then stops dead, and one that runs perfectly and gives the wrong answer.
A syntax error means the text isn't valid code: a bracket missing, a typo in a keyword. The parser can't build a tree, so nothing runs at all, not even the first line.
A runtime error happens while the program is running, when it asks for something impossible: a property of undefined, a function that doesn't exist. The lines before it ran; the lines after never will.
A logic error is the quiet one. The program runs to the end and prints a confident, wrong answer. No message, no line number. This is the bug the rest of this chapter hunts:
The average of 4, 5 and 6 is 5. Can you see why it isn't? Don't fix it yet: the chapter will catch it four different ways.
02 · EXCEPTIONS
When a function hits a problem it can't handle itself, it can throw an exception: an object that describes what went wrong. Throwing stops the function at once. The exception travels down the call stack, abandoning each function in turn (this is called unwinding), until it reaches code that said it can deal with it: a try block with a catch.
On the stage, main calls loadScores, which calls parseLine, which calls toNumber("x"). The exception drops through three frames and lands in main's catch, the green ring. The program carries on.
Press Step through: watch the call stack fill up, then jump straight from toNumber to the catch. Then move the try inside loadScores and see what changes.
finally runs whether the code succeeded, threw, or returned early: it is where clean-up goes (closing a file, releasing a lock). Without any catch, an exception goes all the way down, and the program, or in a browser this one task, stops with "Uncaught".
Errors can carry more than a message. Make your own kind by extending Error, catch only what you understand, and pass the rest on. When you add context, keep the original as the cause, so nothing is lost:
Asynchronous code throws too. With await, a failed promise becomes an ordinary exception you can catch with the same try. (Chapter 9 is about async.)
03 · ERRORS AS VALUES
Throwing is one way to report a problem. The other is to return it, as an ordinary value. JavaScript does both. Some built-ins answer "no" with a special value: indexOf returns -1, Number("x7") returns NaN, a missing key gives undefined. They never crash, which is exactly the danger: NaN quietly spreads through every sum it touches.
The stage shows the deliberate version. parse never throws. Every input comes out as a result with either a value or an error in it, and the code that called it has to look before it uses it: green to one bin, red to the other.
Some languages are built on this idea. Go functions return the error as a second value, and checking it is a habit the whole language leans on. Rust returns a Result that is either Ok or Err, and the compiler refuses to let you use the value without deciding what to do with the error.
n, err := strconv.Atoi("x7")
if err != nil {
// the error is an ordinary value: you must look at it
return fmt.Errorf("reading score: %w", err)
}
fmt.Println(n)
fn read_score(text: &str) -> Result<i32, ParseIntError> {
let n = text.parse::<i32>()?; // ? : on Err, return it to the caller
Ok(n)
}
match read_score("x7") {
Ok(n) => println!("got {n}"),
Err(e) => println!("skipped: {e}"), // the compiler makes you handle both
}
Which to use? A rough rule: return errors that are a normal part of the job (bad user input, a file that may not exist), and throw for things that shouldn't happen if the program is correct. Whichever you choose, never let an error vanish: a catch that does nothing, or a NaN nobody checks, turns a loud bug into a silent one.
04 · STACK TRACES
When an exception isn't caught, it arrives with a stack trace: a snapshot of the call stack at the moment it was thrown. It looks like noise until you know how to read it. On the stage, each line of the trace is wired to the frame it came from.
TypeError: xs is not iterable means something tried to loop over a value that can't be looped over.file:line:column. Here, line 3, inside average.report on line 7, called by main on line 11, called from line 14.Grace has no scores. The runner shows where it stopped. Which line is the real bug: line 3, or the data on line 10?
The trace is a value you can read from code, too: it lives in err.stack. (It isn't part of the ECMAScript standard yet, but every major engine has it; each formats it slightly differently. Here the sandbox names your code your-code.js, and the bottom frame says eval because that is how the sandbox starts your file.)
Python prints its trace the other way up, oldest call first, with the error at the bottom, and points at the exact expression that failed:
Traceback (most recent call last):
File "stats.py", line 14, in <module>
main()
File "stats.py", line 12, in main
print(report(p))
File "stats.py", line 8, in report
return p["name"] + ": " + str(average(p["scores"]))
~^^^^^^^^^^
KeyError: 'scores'
Two habits make traces useful. First, read the whole message: it usually says exactly what is wrong. Second, the top frame is where the program noticed; the cause is often further down, in your code, where a bad value was made. Skip frames inside libraries until you reach a file you wrote.
05 · THE BUG HUNT
Debugging is the scientific method applied to a program. Beginners often change things at random until the bug goes away; studies of novice programmers find exactly that, and it is slow. The loop on the stage is faster:
The simplest test is a print statement: a probe that reports what is true at one moment. Logging an object, { i, x: xs[i], sum }, labels every value for free:
The first probe says i: 1. The first item, 4, at index 0, is never visited. Prediction failed, bug found.
A good reproduction is also a small one. Shrink the failing input until the bug has nowhere to hide. Here, one item is enough, and the answer 0 almost names the bug for you:
06 · BISECT
Sometimes you know the code worked last week and is broken today, but not which of the changes since broke it. Don't read every change. Test the one halfway between the good and the bad: whichever way it goes, half the suspects are cleared. This is binary search, and it is called bisecting.
On the stage, 16 commits: the bracket closes from 0 to 15, then 7 to 15, 7 to 11, 9 to 11, 10 to 11. Four tests, and commit 11 is the culprit. This program does exactly the same:
Change FIRST_BAD to any number from 1 to 15. It never takes more than 4 tests.
Git does this for you. Mark one commit good and one bad, and git bisect checks out the middle commit for you to test; give it a test script and it runs the whole search alone:
$ git bisect start
$ git bisect bad # the commit you're on is broken
$ git bisect good v1.4 # this old release worked
Bisecting: 7 revisions left to test after this (roughly 3 steps)
$ npm test # ...test the commit git checked out
$ git bisect good # or: git bisect bad
...
a1c9e2f is the first bad commit
$ git bisect reset
# or let a script decide every step for you:
$ git bisect run npm test
The same trick works on anything you can cut in half: comment out half of a function, delete half of an input file, turn off half of the settings. Whatever still fails holds the bug.
07 · THE DEBUGGER
A debugger lets you pause a running program and look around. You set a breakpoint on a line (the red dot on the stage); when execution reaches it, everything freezes. Now you can read every variable, see the call stack, and step: run one line, step into a function call, or step out of the current function.
On the stage the debugger pauses on line 4 each time round the loop. The watch panel shows i = 1 on the first stop, and sum ends at 11 instead of 15. The Step through button on every code block in this course is a small debugger. Try it here:
Step forward until line 4 runs for the first time. What are i and xs[i]? Where did the 4 go?
Every browser has a full debugger built in (open DevTools, go to Sources, or Debugger in Firefox, and click a line number), and so do VS Code, Python (pdb) and C (gdb). Worth learning early: conditional breakpoints (pause only when i === 500), logpoints (print without editing the code), and pausing on any exception. Writing debugger; in JavaScript pauses there when DevTools is open.
08 · KINDS OF TEST
Debugging finds a bug once. A test is a small program that checks your program, so the bug can never come back unnoticed. At its heart a test is just: run the code, compare the result with what you expected, complain loudly if they differ. That comparison is an assertion.
Here is a whole test runner in ten lines, testing our buggy average. Real ones (Jest, Vitest, Node's built-in node:test, Python's pytest) add nicer reports, but do the same:
Three red crosses. Fix the loop to start at 0: two go green. The third needs a decision, not a fix: what should the average of nothing be? Tests force you to decide.
Tests come in sizes, and the stage stacks them as a pyramid. At the bottom, many unit tests: one function, no network, no disk, milliseconds each. In the middle, fewer integration tests: several parts together, often with a real database or file. At the top, a few end-to-end tests that drive the real app like a user, through a browser. Higher tests catch more kinds of bug but are slower and break for more reasons, so you want fewer of them.
Integration tests find bugs that live between units. Both functions below are correct on their own; real input, with a blank line in it, breaks them together:
// tests/scores.spec.js · Playwright drives a real browser
import { test, expect } from "@playwright/test";
test("a teacher sees the class average", async ({ page }) => {
await page.goto("http://localhost:3000/class/7b");
await page.getByLabel("Upload scores").setInputFiles("fixtures/7b.csv");
await expect(page.getByRole("cell", { name: "Average" })).toContainText("5.0");
});
Assertions aren't only for tests. Put them in your code to state what must be true (an invariant), so a wrong assumption fails loudly and early, near its cause:
09 · TEST FIRST
Test-driven development (TDD) turns the order round: write the test before the code. Kent Beck set it out as a cycle of three short steps, the three lamps on the stage:
This is the red step. Make it green yourself in the exercise Test first: make it green below.
Why bother? Writing the test first makes you decide what the code should do before you are attached to how it does it, keeps every step small, and leaves a full test suite behind as a side effect. It suits code with clear inputs and outputs best. When exploring an idea you don't understand yet, many programmers sketch first, then write the tests once the shape is clear. Either way, when you fix a bug, write the test that reproduces it first: a regression test, which makes sure that bug stays fixed.
10 · PROPERTY TESTS
An example test checks one input you thought of. A property test states a rule that must hold for every input, then lets the computer try hundreds of random ones. For sorting: the output is in order, and it has the same items as the input.
When an input breaks the rule, the tester shrinks it: it keeps trying smaller versions while they still fail, so you get the simplest example of the bug, not a messy one. On the stage, 13 random lists pass, the 14th fails, and it shrinks from [13, 13, 11, 13, 2] to [10, 2]. Run the real thing:
The bug: sort() with no compare function sorts numbers as text, so "10" comes before "2". Fix it with sort((a, b) => a - b) and run again: 200 lists pass.
The idea comes from QuickCheck (Claessen and Hughes, 2000) for Haskell. Today there is a library for most languages: fast-check for JavaScript, Hypothesis for Python. Good properties to look for: a round trip (decode what you encode and get it back), an invariant (sorting never changes the length), or a slow but obviously right version to compare against.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_keeps_order_and_items(xs):
out = my_sort(xs)
assert all(a <= b for a, b in zip(out, out[1:]))
assert sorted(out) == sorted(xs)
# Falsifying example: test_sort_keeps_order_and_items(xs=[10, 2])
Its wilder cousin, fuzzing, throws huge numbers of random or mutated inputs at a program looking for crashes. Google's OSS-Fuzz runs it nonstop on open-source projects. It has found bugs in some of the most-used code there is.
11 · COVERAGE
Code coverage measures which parts of your code ran while the tests ran: the share of lines (line coverage), or of branches, the paths through each if (branch coverage). Tools such as Istanbul (behind Jest's and Vitest's --coverage) and Python's coverage.py add counters to your code to measure it.
Coverage is good at one thing: showing code that no test touches, so you know where to look. It cannot tell you the tests are good. On the stage the gauge reaches 100%, and the bug is still sitting on its line:
Every line ran, the "test" passed, the answer is still wrong. A test only catches what it checks.
Coverage also can't see code that isn't there. The missing if (xs.length === 0) on the stage is a line no tool can report as uncovered. Studies agree: once you account for how many tests a suite has, coverage is only weakly linked to how many bugs it catches. Google's engineers suggest roughly 60% as acceptable, 75% as commendable and 90% as exemplary, and warn against chasing 100%.
12 · TYPES, LINT, LAYERS
The cheapest bug is the one caught before the code even runs. Two kinds of tool read your code without running it (static analysis) and work like tests you never had to write.
A type checker knows what kind of value every variable can hold and refuses code that mixes them up. TypeScript would have caught Grace's missing scores from the stack-trace act before anyone ran it:
type Player = { name: string; scores?: number[] }; // scores may be missing
function report(p: Player) {
return p.name + ": " + average(p.scores);
// ~~~~~~~~
// error TS2345: Argument of type 'number[] | undefined' is not
// assignable to parameter of type 'number[]'.
}
A linter (ESLint for JavaScript, Ruff or Pylint for Python) looks for patterns that are legal but usually wrong: == where you meant ===, a variable you never use, code that can never run. Here is a toy one; real linters read the parse tree, not raw text:
No single check catches everything, and that is the point of the last station. Each layer is a sheet with holes in it: types, lint, unit tests, code review, then a canary release that shows new code to a small share of real users first. Most bugs hit a solid part of some sheet. A bug only reaches everyone when the holes line up.
The Pipeline page lets you ship a bad commit through all of these layers and watch a canary catch it. Bugs That Changed History replays what happened when the holes did line up: in July 2024, a CrowdStrike content update whose template expected 21 input fields, while the sensor supplied 20, crashed about 8.5 million Windows machines at once, and a staged rollout was one of the fixes promised afterwards.
PRACTICE
Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.
The bug from the whole chapter, now yours to fix. average([4, 5, 6]) should be 5 and average([7]) should be 7. Use Step through and watch i before you change anything.
safeTotal adds up numbers from lines of text, but one bad line crashes the whole thing. Make it skip a bad line (and say so) and keep going, so safeTotal(["4", "x", "6"]) returns 10. Don't change toNumber.
Run it and read the error: it says formatName failed. But formatName is fine. Follow the trace down to the caller and find out why it was handed undefined. Make greet("2") return Hello, Grace Hopper.
Two of the five tests fail. Fix isLeapYear so every test passes, without changing the tests. (The Gregorian rule: every fourth year is a leap year, except years divisible by 100, except years divisible by 400.)
The tests are written; median isn't. Write it so all four pass. The last test is there to catch a famous JavaScript trap.
chunk should split a list into pieces of a given size, with a shorter last piece if needed. A property test throws 300 random lists at it and reports the first one that breaks the rule "glue the chunks back together and you get the input". Run it, read the counterexample, fix chunk.
Versions 0 to 63: version 0 is good, 63 is bad, and somewhere one version broke things. Checking a version is slow, so you may call isGood at most 6 times. The starter checks them one by one and uses far too many calls. Replace the search.
RECALL
Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.
Name the three kinds of error, and which one is the most dangerous.
When an exception is thrown, what happens to the call stack?
In a stack trace, what does the top line tell you, and what do the lines below it tell you?
You have 1,000 commits and one of them broke the build. About how many do you need to test with bisect?
What is the difference between a unit, an integration and an end-to-end test?
Your tests have 100% line coverage. What does that prove?
GO FURTHER
SOURCES