Skip to content

Empty, Null, and Boundaries

The previous page taught you which counts to try — zero, one, many. It ended on a hint: one and many naturally hand you off to the exact edges between counts, like 49/50/51 around a page limit of 50. This page picks up that thread and sharpens it into the two most productive point-testing lenses there are.

They pair naturally because both are about the edge of a category. The first lens asks: what does “nothing” mean here, and did the code handle every flavor of it? The second asks: where are the lines the code draws — the limits, thresholds, and ranges — and did it draw them one step off? Empty and null find the crash on absent data. Boundaries find the off-by-one. Between them they account for a huge share of the bugs that reach production.

There is no single value called “nothing.” There is a family of them, and code that handles one member gracefully often crashes on a sibling. A tester’s job is to name every member and try each one, because to the program they travel down different code paths.

null / nil / None ──► there is no object at all. Dereferencing it
(`user.name`) crashes: the classic NullPointerException.
empty string "" ──► an object exists; it just has length 0. Passes a
"not null" check, fails a "not blank" one.
whitespace " " ──► length > 0, looks empty to a human, is NOT empty to
`if len(s) == 0`. Trims to nothing. The sneakiest member.
empty collection [] ──► a real list with zero elements. `[0]` throws;
`for x in xs` runs zero times; `sum(xs)` is 0.
zero 0 ──► a real, present number that happens to be falsy. Trips
`if not count`, divides badly, and means "none" and
"the value 0" at the same time.

The trap is that these are easy to conflate in code. A validation like if not name: in Python is true for None, "", and the integer 0 and the empty list — but false for whitespace-only " " (a subtle bug) — so one careless check treats several different situations as one. The tester’s discipline is to keep them separate and ask, member by member, “does the code do the right thing for this kind of nothing?”

Null and empty at least look absent, so developers sometimes remember them. The two that slip through review are whitespace-only strings and zero.

  • Whitespace passes almost every naive emptiness check. " " has a positive length, is not null, and is not "" — so a “required field” that checks only those three lets a user submit a username, comment, or file name that is visibly blank. The fix is to trim before checking; the test is to submit " " and a single tab and assert it’s rejected.
  • Zero is a genuine value wearing absence’s clothes. In most languages 0 is falsy, so if not quantity: treats an order of zero items the same as a missing quantity — but “the user chose 0” and “the user chose nothing” are different facts. The same trap catches an empty cart total of $0.00, a page count of 0, and a valid ID of 0.

Boundary-value analysis: the edges of a range

Section titled “Boundary-value analysis: the edges of a range”

Now the second lens. Wherever the code makes a decision based on a threshold — a maximum length, a minimum age, a page size, an array index, a price tier — it draws an invisible line, and bugs cluster on that line. They cluster there because the line is exactly where the programmer had to choose between < and <=, between “up to N” and “up to and including N,” between counting from 0 and counting from 1. Each choice is a coin flip they can lose.

Boundary-value analysis (BVA) is the discipline of testing, for every limit, three points: one below the limit, exactly at the limit, and one above. Not the middle of the valid range — the middle is where nothing interesting happens.

Rule: "age must be at least 18" limit = 18
17 18 19
│ │ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│ just│ │ AT │ │ just│
│below│ │limit│ │above│
└─────┘ └─────┘ └─────┘
reject? accept? accept?
▲ ▲
│ └─ is it `>= 18` (accept 18) or `> 18` (reject 18)? <- the bug lives here
└─ does 17 get the right rejection, or an off-by-one accept?

The value at the boundary is the one that distinguishes > from >=. If you only test 15 (clearly too young) and 25 (clearly old enough), both pass whether the code says age >= 18 or age > 18 — and the second one silently rejects every 18-year-old. Only the value 18 itself tells the two implementations apart. That is why the on-the-line case is non-negotiable.

Off-by-one: the boundary bug that eats programmers

Section titled “Off-by-one: the boundary bug that eats programmers”

The single most famous class of bug — the off-by-one — is definitionally a boundary bug. It is what happens when the code draws the line one step too early or one step too late.

an array of length 5: index: 0 1 2 3 4 (5)
▲ ▲ ▲
first valid last valid ONE PAST THE END
index 0 index 4 index 5 = crash
`for i in range(len(xs) + 1):` reads xs[5] -> out of bounds
`for i in range(1, len(xs)):` silently skips xs[0]

Off-by-one bugs are a family, and each member lives at a boundary:

  • < versus <=. A loop while i < n visits 0..n-1; while i <= n visits 0..n and reads one element past the end. The whole difference is one character, and it only shows at the last index.
  • Index 0 versus length. Arrays start at 0 and end at length - 1. Code that reaches for arr[length] or forgets arr[0] is off by one at exactly one end.
  • Inclusive versus exclusive ranges. “Give me records 1 through 10” and “give me records 1 up to 10” differ by one record. A date range “from Monday to Sunday” — does it include Sunday? A price filter “$10 to $20” — is $20.00 in or out? Every range has two ends, and each end is independently a place to be wrong.

The tester’s move is mechanical: for any range with limits lo and hi, test lo-1, lo, hi, hi+1, and confirm each lands on the side you intended. You are not hoping the boundary is right; you are pinning down which side of the line each edge value falls on.

Strings deserve their own treatment because they are simultaneously a collection (of characters) and a value (with a maximum length), so both lenses apply at once. For any string input, the boundary set is:

"" length 0 ── empty. Does required-validation catch it?
"a" length 1 ── the smallest non-empty string; single-char edge cases,
first/last-char logic with no middle.
"a"×MAX length MAX ── exactly the maximum allowed. Accepted cleanly?
"a"×(MAX+1) length MAX+1 ── one over the limit. Rejected with a clear error,
or silently truncated / DB-error / buffer overrun?

That fourth case — length + 1 — is where the interesting failures live. When a field is capped at, say, 255 characters, the question is what the code does with 256. The good outcomes are “reject with a message” or “the DB column is VARCHAR(255) and the app validates before the insert.” The bad outcomes are a truncation that silently loses data, a database error surfaced as a 500, or — in memory-unsafe languages — a buffer overflow. The only way to know which you built is to submit exactly MAX + 1 characters and watch.

Don’t forget that “one character” is itself a boundary that hides assumptions: an emoji or an accented letter can be one visible character but several bytes, so a length check counting bytes and a limit expressed in characters disagree at the edge. Boundaries and encoding conspire right at length 1.

Every boundary is a decision — and every decision is a place to be wrong

Section titled “Every boundary is a decision — and every decision is a place to be wrong”

Step back and see what unifies both lenses. A boundary is not a property of the data; it is a property of a decision the code makes. “Is this string long enough?” “Is this user old enough?” “Is this the last element?” “Is this quantity present?” Each is a fork in the road the program takes, and every fork was hand-coded by a person who could have picked the wrong branch, the wrong operator, or the wrong bound.

That reframing is the whole point of this page. When you read code — or a spec — you hunt for the verbs of decision: at least, up to, more than, between, before, after, at most, no more than, first, last. Each one is a line drawn on a number line, and each line has an inside, an outside, and a point sitting exactly on it. Your tests should stand on all three. The tester who thinks this way stops asking “does it work?” and starts asking “at every place the code chose a side, did it choose the one the spec meant?”

Under the hood — turning both lenses into a test list

Section titled “Under the hood — turning both lenses into a test list”

The lenses become muscle memory when you generate cases mechanically from the spec, before writing a single assertion. Take a rule and enumerate:

Rule: "username: required, 3–20 characters"
absence lens (the family of nothing):
null / omitted -> reject (required)
"" -> reject (required)
" " / "\t" -> reject (whitespace is not a name)
boundary lens (3 and 20 are the limits):
2 chars (lo - 1) -> reject (too short)
3 chars (lo) -> accept (exactly the minimum)
20 chars (hi) -> accept (exactly the maximum)
21 chars (hi + 1) -> reject / not silently truncated
string edge:
1 char with a multi-byte emoji -> counted as chars or bytes? matches the spec?

Eleven precise cases fall out of one short rule, and every one of them sits on an edge where a real implementation could have gone wrong. The middle of the range — a comfortable 10-character name — appears nowhere, because it teaches you nothing. This is the seam to the next page: boundaries at the edge of valid lead straight to the extremes just past valid — overflow, absurdly large inputs, and outright garbage — where the failures get louder. And when the collection under test can also be reordered or hold repeats, the ordering and duplicates lens layers on top of everything here.

  • Why does it exist? Because programs are built from decisions with edges — limits, thresholds, ranges, and the presence-or-absence of data — and both operators (< vs <=) and human intent (“up to 20” — inclusive?) are precisely the things that go wrong at those edges.
  • What problem does it solve? It turns “test the edge cases” into a mechanical recipe: for every limit, test below/at/above; for every input, try the whole family of nothing. It concentrates effort exactly where off-by-one and null-dereference bugs are densest.
  • What are the trade-offs? It multiplies the case count around each threshold and, on its own, finds edge bugs rather than logic bugs in the middle of the range. It is a where-to-look lens, not a proof of overall correctness — pair it with the other heuristics in this Part.
  • When should I avoid it? Never skip it, but downweight it where a value genuinely cannot reach a boundary (a type-enforced enum with no numeric edge) or where “nothing” is structurally impossible (a non-nullable, always-populated field). Spend the saved effort on the edges that can occur.
  • What breaks if I remove it? The two most common production defects walk straight in: null/blank inputs that crash on the first dereference, and off-by-one errors — the 18-year-old rejected, the last row dropped, the 256th character that corrupts the record.
  1. Name the five members of the “family of nothing” and give one way each is distinct from the others when the code checks for it.
  2. Why are whitespace-only strings and the value 0 described as the “assassins” — what makes them sneak past checks that catch null and ""?
  3. A rule says “age must be at least 18.” Which single test value distinguishes a correct >= 18 implementation from a buggy > 18 one, and why don’t 15 and 25 reveal the difference?
  4. Explain why an off-by-one bug is “definitionally a boundary bug,” using < versus <= in a loop over an array of length n.
  5. For a username field limited to 3–20 characters, list the four string-boundary lengths you would test and say what the MAX + 1 case is specifically hunting for.
Show answers
  1. null — no object exists at all; dereferencing it crashes. Empty string "" — an object of length 0; passes a null check, fails a blank check. Whitespace " " — positive length, not null, not "", so it defeats naive emptiness checks; only trimming catches it. Empty collection [] — a real list with zero elements; [0] throws and loops run zero times. Zero 0 — a present, valid number that is falsy, so it collides with “missing” under an if not x: check.
  2. Because they don’t look absent to the code. " " has positive length and isn’t null or "", so it passes null/empty checks — only a trim reveals it’s blank. 0 is a real value that happens to be falsy, so if not count: conflates “the user chose 0” with “the user chose nothing.” Null and "" at least fail simple presence checks; these two pass them.
  3. The value 18 itself. With >= 18, 18 is accepted; with > 18, 18 is rejected — so 18 is the only input that separates the two. 15 fails both and 25 passes both, so neither can tell a correct implementation from the off-by-one one; the boundary value is where the operator choice becomes visible.
  4. An off-by-one is drawing the line one step too early or too late. For an array of length n, valid indices are 0..n-1. while i < n visits exactly those; while i <= n visits one extra index n, reading past the end. The bug is entirely at the boundary — the last iteration — and is invisible everywhere else in the loop.
  5. 0 ("", must be rejected — too short/required), 3 (lo, exactly the minimum, must be accepted), 20 (hi, exactly the maximum, must be accepted), and 21 (hi + 1, must be rejected). The MAX + 1 case hunts for what the system does one character over the limit: a clean rejection with a message, versus silent truncation, a database error surfaced as a 500, or (in unsafe languages) a buffer overflow.