Boundary values and equivalence classes: how to apply them in practice
You cannot check every possible value of a field: there are far too many. Two techniques solve that together — equivalence classes cut the list down, boundary analysis says which of the remaining values to check first.
Equivalence classes: why one value is enough
A «quantity» field accepts numbers from 1 to 99. Trying 1, then 2, then 3 makes no sense: the service treats them identically. Every value the program handles the same way forms a class — and one representative of a class is enough.
There are usually three classes: valid values, values below the range and above it. On top of those come separate classes for a different data type — an empty field, a string instead of a number, a fraction instead of an integer, the field missing altogether. The last one gets forgotten often, and it behaves differently from an empty value.
The boundary: where things break most often
Bugs rarely live in the middle of a range. They live at the edge, because that is where the programmer chose between «greater than» and «greater than or equal» — and could be off by one. That is exactly what the mistake is called: an off-by-one error.
ISTQB distinguishes two-value and three-value BVA. Two-value BVA uses the boundary and its nearest value in the adjacent partition: 0 and 1, then 99 and 100. Three-value BVA adds the nearest value inside the valid partition: 2 and 98. It costs more but detects more off-by-one variants.
- 0 below the edge expect a refusal
- 1 the edge itself expect success
- 2 inside the range expect success
- 98 inside the range expect success
- 99 the edge itself expect success
- 100 above the edge expect a refusal
Where boundaries hide in an API
A number is the most obvious case but far from the only one. Nearly anything with a size, a length or a term has boundaries.
- Page size: per_page=0, 1, the maximum, the maximum + 1. A frequent find is a parameter silently ignored, with the service returning everything at once.
- Text length: an empty string, one character, the maximum from the requirements, the maximum + 1. And separately: spaces only — formally the string is not empty.
- Date ranges: start equal to end, start later than end, a range one day long, a date in the future.
- Money: zero, the smallest amount, one cent over the balance, a fractional part longer than two digits.
- How often an action is allowed: the last permitted request in the window and the one right after it — the one that should be refused.
What a boundary check looks like
The point is to change exactly one value and compare the answers. If the service answers the same way at the edge and beyond it, there is no check at all — and that is a defect regardless of how it looks on screen.
GET /products?per_page=50 → 200, 50 items in data GET /products?per_page=51 → expect 422 GET /products?per_page=0 → expect 422
The expected result for each line comes from the requirements, not from what you observed. If the requirements name no maximum, that is a finding in itself: with no upper bound a single request can ask for the whole catalogue at once.
What these techniques will not catch
Boundaries and classes work on one field at a time. Defects that come from a combination of fields — a discount plus a quantity plus what is left in stock — will not turn up this way: those need other techniques and plain attention to how values affect each other.
They also say nothing about access rights or about what happens when the same request is repeated. Those are separate kinds of check, and boundaries are only the place to start because they give the most findings for the least time.
Deriving tests from a requirement
Write partitions before choosing values. For “discount is an integer from 0 through 30”, partitions include a valid integer, below 0, above 30, decimal, string, null and missing. Boundaries apply only to ordered partitions; string and null are separate classes, not numerical neighbours.
-1 → below range, reject\n0 → lower boundary, accept\n1 → inside, accept (three-value BVA only)\n29 → inside, accept (three-value BVA only)\n30 → upper boundary, accept\n31 → above range, reject\n2.5, "10", null, missing → separate partitions
One representative is enough only when equal processing is a reasonable model. If zero has a special business meaning or 30 activates another promotion rule, those values belong to separate partitions.
Where the technique stops and what to add
Equivalence is a model, not a fact the system must obey. Revisit partitions after each finding: a defect often reveals a hidden boundary inside a presumed partition.
- Field combinations: use decision tables or pairwise when several conditions determine the result.
- Status changes: use state-transition testing instead of independent input values.
- Permissions and ownership: build an actor × action × object matrix.
- Dates and time: clarify timezone, inclusive boundaries and daylight-saving transitions.
- Money: clarify storage unit, rounding, currency and precision before selecting values.
Worked example: a daily transfer limit of 10,000
An amount field alone does not describe this risk. A daily cap depends on earlier transactions and the calendar boundary. Derive partitions and values from the precise rule: currency, time zone, inclusive edge and whether rejected transfers count.
- Clarify money units and the limit window.
- Partition by accumulated amount.
- Check the edge and one smallest step beyond it.
- Compare response, balance and history.
Write the contract before choosing numbers
Assume amounts are stored in cents, 10,000 RUB is inclusive, the day follows UTC, and only successful outgoing transfers count. A customer-local time zone, rolling 24 hours or conversion at submission time changes the test set. Obtain these details before filing a defect: otherwise 10,000.01 may be correct under another rule.
Partition by state, not only the parameter
For a new account, 1 RUB and 10,000 RUB are valid; zero, negative, sub-cent and 10,000.01 belong to other partitions. After 9,999 RUB has been sent, another 1 RUB should pass and 1.01 should fail. The boundary belongs to “already spent + new transfer”, not merely the new amount. Check that rejection changes neither balance nor the accumulated counter.
Probe neighboring times
A transaction just before and just after 00:00 UTC belongs to different days for a calendar limit. A rolling 24-hour window has a different expected result. Record exact timestamps and time zone. Automate with controlled clocks or prebuilt state rather than waiting for midnight. Daylight saving matters for a local-time rule, not for strict UTC.
Add the concurrency case
Two simultaneous 6,000 RUB transfers can each look valid against an empty counter, yet together exceed the limit. This is no longer pure BVA: it tests atomic updates under concurrency. State the invariant that successful transfers sum to no more than 10,000 RUB per window. A few sequential boundary requests cannot prove this guarantee.
Spent Request Total Expected\n0 10000.00 10000.00 accept; debit once\n0 10000.01 10000.01 reject; no debit\n9999 1.00 10000.00 accept\n9999 1.01 10000.01 reject; counter unchanged\n0 6000+6000 concurrent at most one succeeds
What belongs in the case?
Record accumulated amount and time zone as preconditions, money precision, expected status and error, plus balance, history and counter checks after rejection. If the contract does not say whether a cancelled transfer uses the limit, raise a product question instead of inventing an expected result.
Second case: a promotion with combined conditions
A 10% discount applies only to books when eligible cart value reaches 1,000 RUB and the user has not used the code before. Testing quantity alone misses the rule. Combine conditions, then probe the cart-value boundary and single-use state.
List the conditions separately
The factors are eligible-book total relative to 1,000 RUB, non-eligible goods and code-use history. Do not assume “cart total” includes every item: the threshold might apply only to books. Ask whether shipping counts and whether a refund restores code eligibility. Without these answers an expected discount can be wrong.
Build a decision table
At minimum include: books below threshold with a fresh code; exactly at threshold; above threshold with a used code; and threshold reached only through ineligible goods. Each row needs accept/reject, final discount and counter effect. If factors are independent, choose rows where each condition changes the outcome instead of blindly enumerating eight combinations.
Probe cent-level boundaries
For 1,000 RUB check 999.99, 1,000.00 and 1,000.01 in eligible goods. If the API stores integer cents, use 99999, 100000 and 100001 in raw data. Also check whether the discount rounds per line or on the total: two five-cent items can expose a difference. Compute the expected total independently.
Check state before and after rejection
A rejected code must not be marked used. A successful checkout should change usage once; order cancellation may restore eligibility only if the rule says so. Read cart and promotion history after every attempt. An API can show the correct error while silently consuming the one-time allowance.
Keep concurrency separate
Two parallel checkouts using one single-use code must not both receive a discount. This is a state and atomicity test, not boundary analysis. State the invariant “at most one successful application” and compare final orders and usage count, not only responses. This shows where BVA ends and transition testing begins.
Eligible books Other goods Used before Expected\n999.99 0 no reject\n1000.00 0 no 10% books\n900.00 200.00 no contract decision\n1000.01 0 yes reject; no counter change\n1000.00 0 no + no one parallel success
Which techniques apply?
Partitions and BVA cover amount, a decision table covers product eligibility, threshold and code state, and state transitions cover reuse. Each technique answers a different question; together they yield a smaller justified set than dozens of random carts.
Common boundary-analysis questions
Do I always need three values at a boundary?
No. Two-value BVA uses the boundary and the nearest value in the adjacent partition. Three-value BVA adds the value on the other side. Choose according to risk and desired coverage strength.
Are an empty string and a missing field one partition?
Not necessarily. Empty string is explicit, null may mean no value, and missing may trigger a default or required validation. Test them separately unless the contract proves identical handling.
Can BVA be applied to an enum?
An enum has no natural numerical order, so BVA usually does not apply. Test valid and unknown values, case handling and absence instead.
The technique does not stick on first reading. It sticks after you check the same boundary in a filter, in a page size and in a daily limit — and see that it is the same check every time.