Equivalence Classes and Boundary Values
You cannot test every input, so stop trying. Splitting an input space into classes that behave alike, then testing the edges — where bugs genuinely live.
You cannot test every input, so stop trying. Splitting an input space into classes that behave alike, then testing the edges — where bugs genuinely live.
A field accepts an age between 18 and 120. How many values do you test?
If you have tried answering that by listing values, you have felt the problem. There are a hundred and three valid ones and an endless supply of invalid ones. Testing "a few, roughly" is not a method either — it is guessing, and guessing misses in predictable places.
By the end of this lesson you will have two techniques that turn that question into arithmetic. They are the highest-return thing in this course: cheap to learn, usable this afternoon, and between them they find most input-handling bugs that exist.
They are a pair, and they answer opposite questions about the same field.
Which values are interchangeable?
Groups of inputs the code treats identically. Test one value from each group and skip the rest, because the rest travel the same path through the code.
This is what stops you testing the same line twenty times.
Which values are definitely not interchangeable?
The edges where one group becomes the next. Test all of
them, closely, because an edge is where a human chose
between < and <=.
This is what finds the bug.
The insight is that inputs are not all different from the code's
point of view. 25, 40 and 73 in an age field travel
exactly the same path: valid, accepted, stored. Testing all
three is testing one line of code three times.
An equivalence class is a set of inputs the software should treat identically. Split the input space into classes, then test one value from each.
For age between 18 and 120:
Class Example Expect
too young (below 18) 10 rejected
valid (18 to 120) 45 accepted
too old (above 120) 150 rejected
not a number "abc" rejected
negative -5 rejected
empty "" rejected
missing (absent) rejectedSeven values instead of infinity, and the coverage is not far off, because each class is a distinct decision the code has to make.
Two things separate people who do this well from people who do it badly.
Split the invalid side too. Most people find the valid
classes and treat "invalid" as one lump. But "abc", -5 and
"" reach different branches and often produce different error
messages — three classes, not one.
Split by behaviour, not by appearance. If ages 18 to 64 and 65 to 120 produce different prices, those are two valid classes even though they look like one range.
Bugs live on boundaries. That is the most reliable
generalisation in testing, and the reason is not mysterious: a
boundary is precisely where a human being wrote a comparison
operator and had to choose between < and <=.
So for every limit, test three values, not one.
Age 17
Just below the limit. Must be rejected.
Age 18
The limit itself. Must be accepted.
Age 19
Just above the limit. Must be accepted.
The middle value is where the money is, and it is the one most
test plans leave out. Somebody wrote if age > 18 where they
meant if age >= 18, and exactly one value in the world reveals
it: the limit itself.
Every boundary gets the same treatment, so a range of 18 to 120 has two of them:
Six values — and with the invalid classes from before, you have tested that field thoroughly in about ten cases.
There is a two-value variant, testing the boundary and one value on the other side, which halves the count and is common when tests are expensive. The three-value form is worth the extra when it is cheap, because it distinguishes "the boundary is wrong" from "the boundary is off by one, in this direction".
Numeric ranges are the obvious case. The forgotten ones are where the real bugs are, and this list is worth keeping:
Zero. In a numeric field, zero is a boundary whether or not the requirement mentions it. Zero items, a zero-pound order, a zero-length input. Falsy-value bugs cluster here.
One, and exactly one. Lists are built for "several" and break at "one" — the grammar goes wrong, a separator appears in the wrong place, "select all" behaves oddly.
Empty. Zero items in a list, zero characters in a field, zero results from a search, a brand new account with no history.
Length limits. A field allowing 100 characters: test 99, 100 and 101.
Page size. If a page shows 20 items, the boundaries are 19, 20, 21 and 40, 41. Off-by-one in pagination is among the most common bugs there is, and it hides a record from a user rather than throwing an error.
Dates. Midnight, the last day of a month, the last day of the year, the 29th of February, the day the clocks change. Each has broken real systems.
Money. The smallest unit — a penny, a cent — and the largest value the field allows. Amounts that do not divide evenly when a discount or a tax is applied.
Time zones. For a user in a different zone from the server, "today" starts at a different moment. A daily report boundary is a boundary.
For length limits in particular, there is a second question after "what is the limit?" — and it is the one that finds the interesting bug.
The browser
a maxlength attribute, or a validation message. Easy to test, and easy to bypass — so it proves nothing on its own.
The API
the check that actually matters, because this is the door a real client comes through. Send the over-long value directly and see what happens.
The database column
the last line of defence. Often a different number from the other two, and it either rejects the write or quietly truncates it.
Two fields multiply. Age has five classes, country has three, and testing every pair is fifteen cases. Add a third field and it is hundreds.
You rarely need all of them, and there is a defensible middle.
Test each class of each field at least once, holding the others at a valid value. Five plus three cases rather than fifteen, and every class is exercised.
Then add the combinations that genuinely interact. If the age limit differs by country, that interaction is the whole feature and deserves its own cases. If the two fields are independent, combining them tests nothing new.
The formal version is pairwise testing — cover every pair of values across all fields, which catches most interaction bugs at a fraction of the full count, and there are tools that generate the set for you. Worth knowing it exists; worth reaching for when you have five or more fields that really do interact.
Bad — five cases, one code path:
Good — five cases, five decisions:
The first set has four tests of one branch and one of another.
It passes comfortably against code that says if age > 18,
because none of its values is 18 — so the off-by-one ships, and
the first eighteen-year-old who signs up is told they are too
young. Five cases run, and the one bug the field was most likely
to have went undetected.
The second set puts one case on each decision the code makes. Identical effort, and it cannot miss the boundary error.
A discount rule, of the kind that turns up constantly:
Classes and boundaries together — the boundaries are where the rules meet, and the interesting values fall out mechanically:
Ten cases from four sentences — and notice what the exercise produced before a single one of them was run. "Over £50" is ambiguous, and £50.00 exactly is undefined by the requirement. Finding that is worth more than running all ten cases, and it came out of boundary analysis rather than from reading the text more carefully.
That is the pattern to expect. Boundary analysis is a bug-finding technique when applied to code, and a gap-finding technique when applied to requirements.
You can now choose input values deliberately rather than by feel. The next lesson changes purpose rather than technique: smoke, sanity and acceptance testing — the same software examined to answer three different questions, which is what tells you when each kind of check is worth running.
Before that, take a numeric or text field in something you already use and apply both techniques on paper. Write the classes, write three values per boundary, then try the "exactly on" ones. That is where you will find something.
17, 18, 19 the lower boundary
119, 120, 121 the upper boundaryAge 25 -> accepted
Age 30 -> accepted
Age 40 -> accepted
Age 55 -> accepted
Age 5 -> rejectedAge 17 -> rejected just below the lower boundary
Age 18 -> accepted exactly on it
Age 120 -> accepted exactly on the upper boundary
Age 121 -> rejected just above it
Age "" -> rejected emptyOrders under £20 no discount
Orders £20 to £99.99 10% off
Orders £100 and over 20% off
Free shipping on orders over £50£0.00 empty order: allowed at all?
£0.01 the smallest possible order
£19.99 just below the first boundary: 0%
£20.00 exactly on it: 10%
£20.01 just above: 10%
£50.00 exactly on shipping: free, or not? "over £50" says not
£50.01 just above: free shipping
£99.99 just below the second boundary: 10%
£100.00 exactly on it: 20%
£100.01 just above: 20%# Equivalence classes: inputs the code treats identically
# split the input space, test ONE value from each class
# split the INVALID side too — "abc", -5 and "" are different
# split by behaviour, not appearance — different price = new class
age 18-120:
below 18 | 18-120 | above 120 | not a number | negative | empty
10 | 45 | 150 | "abc" | -5 | ""
# Boundary values: bugs live where someone chose < or <=
# for every limit, test three: just below, exactly on, just above
17, 18, 19 the lower boundary
119, 120, 121 the upper boundary
# the "exactly on" case is the one that catches the off-by-one
# The boundaries people forget
zero 0 items, £0.00, "" — falsy-value bugs live here
one lists break at exactly one item
empty no results, no history, a brand new account
length limits 99 / 100 / 101 — and WHERE is it enforced?
page size 19 / 20 / 21 and 40 / 41 — pagination off-by-ones
dates midnight, month end, year end, 29 Feb, DST change
money the smallest unit; amounts that do not divide evenly
time zones "today" starts at a different moment for the user
# Ask of every limit
# enforced in the browser? in the API? in the database?
# a limit enforced only in the front end is not enforced
# More than one input
# test every class of every field once, others held valid
# then add only the combinations that genuinely interact
# pairwise tools cover every pair when there are many fields
# The failure mode to avoid
# five values from the same class = one test run five times
# five values on five decisions = five tests