Testing and debugging · Lesson 68 of 77 · practice

Edge cases and boundaries

About 55 minutes.

By the end of this lesson you can

  • Work through a checklist of edge cases instead of picking test values at random.
  • Turn a written rule with a boundary into tests on both sides of that boundary.
  • Say what an empty collection does that a one-item collection does not.
  • Decide, on purpose, what your function does with input you did not plan for.

The happy path is the case you already believed in

Explanation

When people write their first tests they test what they were already thinking about: two plus two, a basket with three items, a name that is a name. Those tests pass on the first run and keep passing forever, and they are worth very little, because the code was written while thinking about exactly those cases.

Bugs do not live in the middle of the range. They live at the ends: the empty list, the single item, the value exactly on the limit, the number that is negative when you assumed it never would be, the text that is empty or all spaces. Every one of those is a decision the author had to make, usually without noticing they were making it. A test at the edge is how you find out which decision got made.

This is why 'how many tests should I write' is the wrong question. The right one is 'which distinct cases does this function actually have', and the answer is usually four or five, not fifteen - and not three values that all do the same thing.

The checklist that replaces guessing

A mental model

Zero, one, many. For anything that takes a collection or text: what happens with an empty one, with exactly one item, and with several? These three are different code paths far more often than they look, and the empty case is the one nobody wrote.

The boundary, and both of its sides. If a rule mentions a number - eighteen or over, up to 100 grams, at most three attempts - then that number is a boundary, and you test the value itself and the value on each side of it. Three tests, and they catch every off-by-one there is.

The value that means nothing. Zero, an empty string, null, undefined, and NaN each arrive from somewhere real: a missing form field, a failed parse, a division you did not check. Decide what your function does with each of them and write that decision down as a test.

The wrong shape. Text where a number was expected, an array where an object was, a negative price. You do not need a test for every possible wrong input, but the ones that a caller could plausibly hand you deserve a stated answer - a thrown error, a null, a default - and that answer is exactly what the errors module was about.

An empty collection is not just a small one

A common mistake

Beginners assume an empty array behaves like a short array. It often does not. Calling reduce on an empty array without a starting value throws a TypeError, because reduce takes the first item as the starting point and there is no first item. That is a documented rule, not a quirk, and it turns a perfectly sensible average function into a crash the day someone hands it an empty list.

Two more that surprise people, and both are worth knowing by heart: every returns true for an empty array, and some returns false. That is not a bug - the claim 'all of these are over 100' cannot be contradicted when there are none - but it means a validity check written with every will happily approve an empty input. And dividing by an empty array's length gives you NaN rather than an error, which then travels quietly through the rest of your program and turns up somewhere unrecognisable.

One warning about the error messages you meet in this book's sandbox and elsewhere. The NAME of a built-in error is stable across engines: reduce on an empty array is a TypeError everywhere. The WORDING of its message is not - this sandbox says 'empty array' where a browser console says something much longer. So assert on the name, or on a message your own code produced, and never on a built-in message. A test that matches the exact text of an engine's error is a test that breaks when someone upgrades their runtime.

A boundary is one number and two answers

Explanation

Take a rule from a real brief: postage costs 1.50 up to and including 100 grams. There is one boundary, 100, and two answers around it. Test 100 - the boundary itself - and 101, the first gram that costs more. Adding 99 costs you a second and proves you did not accidentally write 'below 100' rather than 'up to 100'.

The reason this is worth being mechanical about is that the two common ways to be wrong here look identical while you write them. Less-than and less-than-or-equal differ by exactly one value, and that value is the one nobody ever tries by hand. Testing the boundary itself is the whole of the technique.

Write the boundary tests directly from the sentence in the brief, before you write the code. Then the tests encode the requirement, and if you later misread the requirement while coding, the tests disagree with you rather than agreeing with your mistake.

An edge case you never tested is a decision you never made

Why it matters

Your function does SOMETHING when handed an empty array. It returns NaN, or throws, or quietly gives zero. That behaviour exists whether or not you chose it - the only question is whether you found out on your own machine or from a user.

So the test is not really about catching bugs. It is about turning an accident into a decision: null for 'nothing to average' is a fine answer, throwing is a fine answer, zero is a defensible answer, and each of them changes what callers must do. Writing the test forces you to pick one and say so out loud, which is the part that survives into next year.

What to hold on to

Recap

Test zero, one and many; test every boundary and the value on each side of it; test the values that mean nothing; decide what happens for the wrong shape. Remember that an empty array is a different case, not a small one, and that reduce with no starting value throws on it. Assert on error names, never on an engine's wording. And treat an untested edge as an undecided behaviour, because that is exactly what it is.

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.

The average function that works until the list is empty

The obvious implementation, tested with the obvious input, followed by the one input that was never considered. Note that the second part prints the error's NAME and says nothing about its wording.

1function average(numbers) {2  const total = numbers.reduce(function (sum, value) {3    return sum + value;4  });5  return total / numbers.length;6}7 8console.log(average([2, 4, 6]));9 10try {11  average([]);12} catch (error) {13  console.log(error.name);14  console.log("The wording of error.message is up to the engine; the name is not.");15}
  • line 2

    reduce with no second argument takes the first item as the starting total. That is fine here and fatal below.

  • line 8

    The happy path. Three numbers, one obvious answer, and a test built only from this line would pass forever.

  • line 11

    The empty array. There is no first item to start from, so reduce throws a TypeError before the division is ever reached.

  • line 13

    TypeError - the name, which is the same in every JavaScript engine. Passing 0 as reduce's second argument fixes the throw but leaves you dividing by zero, so the empty case needs an answer of its own either way.

What it prints

4TypeErrorThe wording of error.message is up to the engine; the name is not.

Sweeping both sides of two boundaries

Postage costs 1.50 up to and including 100 grams, 2.90 up to and including 500, and 5 above that. Two boundaries, six values, and the whole rule is pinned down. Read the pairs: 99/100/101 and 499/500/501.

1function postage(grams) {2  if (grams <= 100) {3    return 1.5;4  }5  if (grams <= 500) {6    return 2.9;7  }8  return 5;9}10 11const around = [99, 100, 101, 499, 500, 501];12 13for (const grams of around) {14  console.log(grams, "->", postage(grams));15}
  • line 2

    The whole exercise lives in this operator. Change <= to < and only ONE of the six values below changes its answer - the boundary itself.

  • line 11

    These six numbers are not a sample. They are the boundary values and their immediate neighbours, chosen from the sentence in the brief.

  • line 13

    In a real suite each of these would be its own named test, so a failure names the gram value. A loop is fine for reading the shape of the rule at a glance.

What it prints

99 -> 1.5100 -> 1.5101 -> 2.9499 -> 2.9500 -> 2.9501 -> 5

Check yourself

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

Four questions about an empty array. Two of the answers surprise most people the first time. Predict all four lines.

const empty = [];console.log(empty.every(function (value) { return value > 100; }));console.log(empty.some(function (value) { return value > 100; }));console.log(empty.length === 0);console.log(empty[0]);

The rule is 'a discount applies to orders of 50 or more'. Which set of test values pins that rule down best?

You want a test proving that averaging an empty array throws. What should the test check about the error?

Write it yourself

Exercise · intro · 8 tests

Average, including when there is nothing to average

Fix average(numbers) so that it answers for every input, not only the ones you had in mind. For a non-empty array it returns the arithmetic mean - the sum divided by how many there are. For an empty array it returns null, meaning 'there is no average of nothing', which is a decision rather than an accident. It must not modify the array it was given. The starter is the version from the lesson: correct on the happy path, and a crash on the empty case.

What your code must do

average returns a number for any non-empty array of numbers, including negatives and fractions, and returns null for an empty array. It never throws and never changes the array passed to it. Ordinary floating point applies, so an average of 1, 2 and 2 is 1.6666666666666667 rather than an exact third.

Define average 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 · 9 tests

Clamp a value into a range, exactly

Write clamp(value, min, max), which pulls a number into a range: below min it returns min, above max it returns max, and anywhere inside - including exactly on either end - it returns the value unchanged. You may assume min is never greater than max, but min and max may be equal. This is four lines of code and a minefield of off-by-ones, which is the point: the tests sit on both boundaries and on both sides of them. The starter handles the floor and ignores the ceiling entirely.

What your code must do

clamp returns min when value is less than min, max when value is greater than max, and value itself otherwise - so exactly min gives min and exactly max gives max, both by returning the value unchanged. It works for negative ranges and for fractions, and when min equals max every input returns that number.

Define clamp 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 first three cases to test for anything that takes a collection?

    Zero, one, many. The empty case is a different code path far more often than it looks, and it is the one nobody wrote.

  • A rule says '50 or more'. Which values do you test?

    49, 50 and 51. Only the boundary itself can tell '50 or more' apart from 'more than 50'.

  • What does reduce do on an empty array with no starting value?

    It throws a TypeError - there is no first item to start from. Passing a starting value as the second argument removes the problem.

  • Which part of a built-in error is safe to assert on?

    The name, such as TypeError, which is fixed by the language. The message wording belongs to the engine and changes between versions.

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: Calling reduce on an empty array with no initial value throws a TypeError; when an initial value is supplied the callback is never called and that value is returned.

MDN — Array.prototype.every()

Check there: every returns true for any condition on an empty array.

MDN — Array.prototype.some()

Check there: some returns false for any condition on an empty 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