Skip to content

Boundary Value Analysis

The previous page, Equivalence Partitioning, gave us a way to collapse an infinite input space into a handful of classes: pick one representative from each, and you cover the class. That is efficient, but it quietly assumes something dangerous — that every value inside a class behaves identically. Mostly true. But there is one place inside a partition where behaviour changes sharply, and it is the one place partitioning glosses over: the edge.

Boundary Value Analysis (BVA) is the technique that lives at that edge. Partitioning tells you where the boundaries are; BVA tells you to hammer them. The two are not rivals — they are a pair, and you almost never use one without the other. This page is the second half of a single idea begun in the previous one, and it earns its place in the test-design toolkit precisely because it converts a vague instinct (“test the edges”) into a repeatable, countable procedure.

Everything here rests on one empirical claim that decades of defect data support: bugs are not distributed uniformly across the input space; they pile up at the seams. If that claim is true, then spending your limited test budget in the middle of a partition is spending it where the bugs are least likely to be. BVA is simply the discipline of spending it where they are most likely to be instead.

If you could look at a heat map of where defects hide, it would glow brightest at the boundaries of every range, loop, and buffer. This is not superstition; it follows directly from how humans write conditional logic. Three failure modes account for most of it.

A requirement says “accept ages 18 and over.” The developer writes age > 18. That code rejects an 18-year-old — the boundary itself is wrong by one. The mistake is invisible in the comfortable middle (a 40-year-old is accepted either way) and only shows up when you test exactly on the edge.

“The discount applies to orders up to and including $100.” Is the check total < 100 or total <= 100? Those two operators disagree about exactly one value — $100 — and agree everywhere else. A test at $50 will never distinguish them. Only a test on the boundary can.

Ranges have two ends, and each end can be inclusive (closed, [) or exclusive (open, )). The spec, the code, and the test can each hold a different opinion. “Valid quantity is 1 to 100” — does 100 count? Does 0? The confusion between [1, 100], [1, 100), and (0, 100] produces bugs that live only at the two endpoints.

Notice the common thread: in all three cases the defect is invisible from the middle of the partition and visible only from its edge. That is the entire justification for BVA.

For every boundary you find, you test the values immediately around it. There are two disciplined ways to do this.

  • Two-value (two-point) approach. Test the value on the boundary and the value just across it. This catches the operator and off-by-one mistakes with the fewest cases.
  • Three-value (three-point) approach. Test just below, exactly on, and just above the boundary. This is stricter: it also catches a class of mistakes where the code treats the boundary neighbourhood as a special case.
partition edge at value B
valid partition invalid partition
───────────────────┼───────────────────────►
B-1 B B+1
two-value: ● ● (on, just-above)
three-value: ● ● ● (below, on, above)

“Just below” and “just above” mean the next representable value in whatever type you are testing — for integers that is ±1; for a date it is the previous/next day; for a string length it is one character shorter/longer. Pick the increment that matches the domain, not always literally 1.

Prefer the three-value approach when the boundary is safety- or money-critical, and the two-value approach when you are trading thoroughness for a smaller suite.

Suppose a field accepts integers 1 through 100 inclusive. Partitioning gives three classes: too-low, valid, too-high. There are two boundaries — the lower edge at 1 and the upper edge at 100. Apply the three-value approach to each.

invalid │ valid (1..100) │ invalid
────────────┼──────────────────────────┼────────────►
0 1 2 ... 99 100 101
lower boundary (1): 0 (invalid) 1 (valid) 2 (valid)
upper boundary (100): 99 (valid) 100 (valid) 101 (invalid)

That yields six test values: 0, 1, 2, 99, 100, 101. Every one earns its place:

ValueWhy it is hereCatches
0just below lower edge<= 1 written as < 1 accepting nothing, or 0 wrongly accepted
1on lower edge> 1 off-by-one that rejects the smallest valid input
2just above lower edgeconfirms the valid class is genuinely open here
99just below upper edgeconfirms the valid class holds near the top
100on upper edge< 100 off-by-one that rejects the largest valid input
101just above upper edge<= 100 written as <= 101 accepting an over-limit value

Contrast this with equivalence partitioning alone, which would have picked something like 50 for the valid class — a value that cannot fail on any of the three boundary bug types. BVA spends its six cases exactly where the defects live.

Here is the same idea as an executable check. The function under test is deliberately buggy — it uses < where it should use <= on the upper edge.

def in_range(n):
return 1 <= n and n < 100 # BUG: should be n <= 100
# Boundary suite — note 50 is absent; it proves nothing.
assert in_range(0) is False # passes
assert in_range(1) is True # passes
assert in_range(2) is True # passes
assert in_range(99) is True # passes
assert in_range(100) is True # FAILS <-- boundary catches the bug
assert in_range(101) is False # passes

Only the 100 case fails. A middle-of-the-partition test would have sailed straight past this defect and shipped it.

Explicit numeric ranges are the easy case because the edges are written in the spec. The dangerous boundaries are the implicit ones — the edges of a type or a data structure that no requirement mentions but that the code has anyway. Keep a standing checklist:

  • Zero. The hinge between positive and negative, and the classic divide-by-zero trap. Test it whenever a quantity, count, or divisor can reach it.
  • Empty string and empty collection. A list of length 0, a "", a map with no keys. Loops that assume “at least one element” and code that indexes [0] blindly break here.
  • First and last element. Iterating a collection, the off-by-one lives at index 0 and index n-1. Fencepost errors read or write one slot too far.
  • Maximum integer and overflow. i32::MAX, 2^63 - 1, a 16-bit counter at 65535. Adding one wraps or traps. Counters, timestamps, and accumulators all have a top.
  • Null / absent. The boundary between “a value” and “no value.” A missing optional, a None, a null reference — the edge that produces the most infamous crash of all.

These are boundaries even when the spec is silent, because the machine imposes them. A 32-bit integer has an edge at roughly 2.1 billion whether or not anyone wrote it down.

It is worth stating the relationship plainly, because the two techniques are almost always taught and used together:

  1. Partition first. Divide the input space into classes where behaviour is expected to be uniform. This locates the edges — every boundary between two classes is a place to test.
  2. Then apply BVA at each edge. For every boundary the partitioning surfaced, generate the two- or three-value cases around it.
Equivalence Partitioning ──► "here are the edges"
Boundary Value Analysis ──► "here is how to hit each edge precisely"

Partitioning gives you breadth — one case per class so nothing is unrepresented. BVA gives you precision at the seams — the exact spots where a class boundary can be off by one. Use partitioning to keep the suite small; use BVA to keep it sharp. Neither alone is enough: partitioning without BVA misses off-by-ones; BVA without partitioning has no principled way to know where the edges even are.

Under the hood — where the edge actually is

Section titled “Under the hood — where the edge actually is”

“Just above the boundary” only means something once you know the type’s granularity. The next value after 100 is 101 for an integer, but for a floating-point number it is 100.0000000000001… (the next representable double), and for a currency amount stored in cents it is 10001 cents, i.e. $100.01. Get the granularity wrong and your “just above” test lands in the wrong partition, quietly testing nothing. When the boundary is a floating-point comparison, remember that 0.1 + 0.2 != 0.3 in IEEE-754 — the boundary you wrote and the boundary the hardware compares may not be the same number. In those cases test at the representable neighbours and against a tolerance, not at the human-written literal.

  • Why does it exist? Because defects are not spread uniformly across the input space — they concentrate at the edges of equivalence classes, where comparison operators and loop bounds are written. BVA points testing effort at that concentration instead of the safe middle.
  • What problem does it solve? It systematically catches off-by-one errors, <-versus-<= mistakes, open/closed interval confusion, empty-collection and overflow bugs — the exact failures a middle-of-the-partition test can never reveal.
  • What are the trade-offs? More cases per boundary (three-value costs 50% more than two-value), and you must correctly identify the type’s granularity to know what “just above” means. In exchange you get near-complete coverage of the most common bug family for a tiny number of tests.
  • When should I avoid it? When the input is genuinely unordered with no meaningful edges (a set of unrelated enum flags), or when a partition has no boundary at all — there is nothing to sit just below or just above. Reach for decision tables or pairwise testing instead.
  • What breaks if I remove it? Your suite keeps testing comfortable middle values and ships off-by-ones and overflow bugs. Ariane 5 is the extreme illustration: skip the boundary and the boundary finds you.
  1. Why can a test at the middle of a valid partition never distinguish total < 100 from total <= 100, and which single value can?
  2. For a field valid over 1..100 inclusive, list the three-value boundary cases and label each as valid or invalid.
  3. Name three “implicit” boundaries that no requirement mentions but that the code has anyway, and give a bug each one produces.
  4. Explain the division of labour between equivalence partitioning and boundary value analysis in one sentence each.
  5. In the Ariane 5 failure, what was the boundary, why did the same code pass on Ariane 4, and what testing rule would have caught it?
Show answers
  1. The two operators disagree about exactly one value — 100 — and agree on every other input, so any middle value (say 50) yields the same result under both. Only a test on the boundary, at 100, can tell them apart: < 100 rejects it, <= 100 accepts it.
  2. Lower edge: 0 (invalid), 1 (valid), 2 (valid). Upper edge: 99 (valid), 100 (valid), 101 (invalid). Six cases total: 0, 1, 2, 99, 100, 101.
  3. Any three of: zero (divide-by-zero, sign hinge); empty string/collection (loops or [0] indexing that assume at least one element); first/last element (fencepost errors reading n or -1 instead of n-1); max int / overflow (a counter wrapping or trapping past its top); null/absent (dereferencing a missing value crashes).
  4. Equivalence partitioning locates the edges by dividing the input space into classes of uniform behaviour (breadth). Boundary value analysis tests precisely at each of those edges with two- or three-value cases (precision at the seams).
  5. The boundary was the maximum value of a 16-bit signed integer (~32,767) that a 64-bit velocity was converted into. On Ariane 4’s flatter flight profile the velocity stayed under that ceiling, so the untested conversion never overflowed; Ariane 5’s steeper profile pushed it over. The rule: reusing code against a wider input range requires re-running the boundary analysis for the new range — a max-integer edge is a boundary even when no requirement names it.