Ask a developer to accept quantities from 1 to 100 and you will usually get correct code. Ask a hundred developers and some will write quantity < 100, some will validate on the client but not the server, and one will store the value in a column that cannot hold 100 with two decimals. None of those bugs shows up with a quantity of 50.
Boundary value analysis (BVA) is the discipline of testing where those bugs are: at the edges of each valid range. It is one of the oldest black-box techniques in testing, it is part of the ISTQB Foundation syllabus, and it is still one of the highest-yield ways to spend a small number of test cases.
Start with equivalence partitions
Before choosing values, find the ranges. Equivalence partitioning divides the input space into classes that the system should treat the same way. For an age field that accepts 18 to 65:
| Partition | Range | Expected |
|---|---|---|
| Too young | ≤ 17 | Reject |
| Valid | 18 – 65 | Accept |
| Too old | ≥ 66 | Reject |
Partitioning on its own would test one value per class — say 10, 40 and 80. That proves each class exists and says nothing about where the lines between them are drawn. Boundary value analysis picks the values at those lines.
Two-value and three-value BVA
Two-value BVA tests each boundary and its closest neighbour on the other side:
17, 18, 65, 66
Three-value BVA adds the closest neighbour on the same side:
17, 18, 19, 64, 65, 66
Three-value BVA catches an extra class of defect: a boundary implemented as a point rather than a range, such as age == 18 || (age > 19 && age <= 65) from a botched refactor. The cost is two extra cases per range, which is almost always worth paying.
Worked examples
A string length
A username must be 3 to 20 characters.
| Case | Value | Expected |
|---|---|---|
| Empty | "" |
Reject |
| min − 1 | ab |
Reject |
| min | abc |
Accept |
| min + 1 | abcd |
Accept |
| max − 1 | 19 characters | Accept |
| max | 20 characters | Accept |
| max + 1 | 21 characters | Reject |
Then ask the question that turns a good test into a great one: 20 of what? Twenty bytes, twenty UTF-16 code units, or twenty user-perceived characters? José is four characters and five UTF-8 bytes; a family emoji is one character, seven code points and twenty-five bytes. Repeat the max and max + 1 cases with emoji and accented characters, and the Unicode edge case generator will usually find a second boundary nobody specified.
Money
A transfer must be between 0.01 and 10,000.00.
| Case | Value | Expected |
|---|---|---|
| Zero | 0.00 |
Reject |
| min | 0.01 |
Accept |
| max | 10000.00 |
Accept |
| max + smallest unit | 10000.01 |
Reject |
| Sub-cent | 0.005 |
Reject, or round — decide which |
| Thousands separator | 1,000.00 |
Decide which |
The "smallest unit" for money is the currency's minor unit, not 1. And sub-unit values expose the real question: does the system reject, truncate or round? All three are defensible. Silently doing different things on the client and the server is not.
A date range
A booking must be at least one day and at most 365 days ahead.
The boundaries are not just "tomorrow" and "day 365". They are midnight in whose time zone? A user in Auckland and a server in UTC disagree about what "tomorrow" means for up to thirteen hours a day. Test the boundary at 23:59 and 00:01 local time, in a zone far from UTC, and across a daylight-saving change, where one local day is 23 or 25 hours long.
Several inputs at once
Testing every combination of boundaries across several inputs explodes quickly: six values for each of five inputs is 7,776 cases. The standard simplification is the single-fault assumption — most failures are caused by one input at a time — so you vary one input across its boundaries while holding the others at a nominal, safely valid value.
For each input, that gives min, min + 1, max − 1 and max, plus one shared case where everything is nominal: 4n + 1 test cases for n inputs. Adding the invalid values just outside each range (min − 1 and max + 1) gives 6n + 1. Five inputs need 21 or 31 cases, not thousands.
Where inputs interact — a discount that depends on both quantity and customer tier — add the boundary combinations for that pair specifically, rather than for everything.
The boundaries nobody wrote down
The specification gives you the explicit boundaries. The implementation adds its own, and these are the ones that reach production:
- Integer types. A field validated as 1–100 in the UI may be parsed into a 32-bit integer on the server before validation runs. 2,147,483,648 overflows; 9,007,199,254,740,993 cannot be represented exactly in JavaScript.
- Storage.
VARCHAR(255)counts characters in some databases and bytes in others, and 255 four-byte emoji are 1,020 bytes. - Floating point.
0.1 + 0.2is not0.3. A total compared with===against a limit can fail at exactly the boundary. - Calendars. 29 February, 31 April (invalid), the last day of each month, the 2038 overflow of 32-bit Unix time, and the year 10,000 for four-digit year fields.
- Pagination. The last page, a page with exactly one item, page 0, and a page beyond the end.
- Rate limits and quotas. The 100th request in a window, and the 101st.
- Client versus server. The UI blocks 101; does the API? Send boundary values to both.
The boundary value generator produces these hidden cases alongside the explicit ones — type limits, NaN, -0, leap seconds, empty arrays, null and missing keys — with an expected result for each.
Automate the table
A boundary table with an expected-result column is already a test. In pytest:
import pytest
CASES = [
("below minimum", 17, False),
("minimum", 18, True),
("minimum + 1", 19, True),
("maximum - 1", 64, True),
("maximum", 65, True),
("above maximum", 66, False),
]
@pytest.mark.parametrize("name,age,accepted", CASES, ids=[c[0] for c in CASES])
def test_age_validation(name, age, accepted):
assert is_valid_age(age) is accepted
Named cases matter: a failure that says test_age_validation[maximum] tells you which edge broke without opening the code.
BVA pairs well with property-based testing. Libraries such as Hypothesis (Python) and fast-check (JavaScript) generate many inputs from a range and shrink failures to the smallest counter-example — which, more often than not, turns out to be a boundary.
When to agree before you test
BVA cannot rescue an ambiguous requirement. "Up to 100" might include 100 or not; "within 30 days" might mean 30 × 24 hours or 30 calendar dates. Settle inclusive versus exclusive, units, and time zones before writing the table. Most boundary "bugs" found late are really two people reading the same sentence differently — and the cheapest place to find that out is in the table, before the code exists.