Off-by-one errors are the most ordinary bug there is. Someone writes < where the rule needed <=, loops to length instead of length - 1, or counts days from the wrong end of a range. The code works for almost every input, which is exactly why it ships: the inputs people try while developing are rarely the one value where the comparison flips.
Boundary-value testing is the habit of aiming tests right at those flip points. For every limit in a rule, you test the value just inside it, the value on it, and the value just outside it. It’s a cheap habit with a very good return, and it’s one of the first test-design techniques interviewers expect you to name when asked “what would you test here?”
Reading the boundary out of the requirement
Before you can test a boundary you need to know which side of it a value falls on, and requirements are often vague about that. “Customers can have up to 10 items in their cart” means 10 is allowed. “Fewer than 10” means it isn’t. “Orders over 50.00 ship free” leaves 50.00 itself unclear, since people use “over” loosely in everyday speech. When the wording doesn’t settle it, ask. Writing the test forces the question, and the answer belongs in the test name so the next reader doesn’t have to ask again.
Some boundaries are written down and some aren’t. A page-size parameter documented as 1 to 100 has two explicit edges. The same parameter also has implicit ones: zero, a negative number, a value larger than the database’s integer type, an empty string where a number was expected. Dates have month ends, leap days and daylight-saving changes. Strings have a maximum length, and the question of what “length” counts. Listing these is half the work.
Three values at a ten-item cart limit
Here’s a cart rule with a limit of ten items and a free-shipping threshold of 50.00. Both functions look reasonable at a glance.
MAX_ITEMS = 10
def can_add(items_in_cart: int, qty: int) -> bool:
if qty < 1:
return False
return items_in_cart + qty < MAX_ITEMS
def free_shipping(prices: list[float]) -> bool:
total = 0.0
for price in prices:
total += price
return total >= 50.00The product rule says a cart holds up to ten items, so ten is allowed. The interesting totals after an add are 9, 10 and 11. Using subTest keeps each case reported separately, so one failure doesn’t hide the others:
import unittest
from cart import can_add, free_shipping
class ItemLimitTest(unittest.TestCase):
def test_item_limit_boundaries(self):
cases = [
(8, 1, True), # ends at 9: just below the limit
(9, 1, True), # ends at 10: exactly the limit
(10, 1, False), # ends at 11: just above
(0, 10, True), # one add straight to the limit
(0, 11, False), # one add straight past it
(5, 0, False), # zero is not an add
]
for in_cart, qty, expected in cases:
with self.subTest(in_cart=in_cart, qty=qty):
self.assertEqual(can_add(in_cart, qty), expected)
class FreeShippingTest(unittest.TestCase):
def test_just_below_threshold(self):
self.assertFalse(free_shipping([30.49, 19.50])) # 49.99
def test_exactly_at_threshold(self):
self.assertTrue(free_shipping([4.50, 30.49, 15.01])) # 50.00Running python3 -m unittest -v test_cart (Python 3.14) gives three failures, trimmed here to the useful lines:
test_exactly_at_threshold (test_cart.FreeShippingTest.test_exactly_at_threshold) ... FAIL
test_just_below_threshold (test_cart.FreeShippingTest.test_just_below_threshold) ... ok
test_item_limit_boundaries (test_cart.ItemLimitTest.test_item_limit_boundaries) ...
test_item_limit_boundaries (...) (in_cart=9, qty=1) ... FAIL
test_item_limit_boundaries (...) (in_cart=0, qty=10) ... FAIL
FAIL: test_item_limit_boundaries (test_cart.ItemLimitTest.test_item_limit_boundaries) (in_cart=9, qty=1)
AssertionError: False != True
Ran 3 tests in 0.001s
FAILED (failures=3)Look at which cases failed. Both item-limit failures land exactly on 10, the boundary. The cases at 9 and 11 passed, and so would any test that used “typical” values like 3 items or 15 items. That’s the whole argument for boundary testing in one output: a suite of middle-of-the-range tests would have been green.
Notice also what the zero-quantity case is doing. It isn’t a boundary of the ten-item limit; it’s the lower edge of the qty parameter. Every input has its own edges, and a function with two parameters has two sets of them.
A threshold that floats move
The free-shipping failure is a different kind of boundary bug, and it’s sneakier. The three prices add up to exactly 50.00 on paper. In binary floating point, accumulating them one at a time gives:
total = 0.0
for p in [4.50, 30.49, 15.01]:
total += p
print(repr(total))
print(sum([4.50, 30.49, 15.01]))49.99999999999999
50.0JavaScript gives the same 49.99999999999999 for 4.50 + 30.49 + 15.01. The comparison >= 50.00 is correct; the value reaching it is a hair short. A customer whose basket is exactly at the threshold pays for shipping.
The second line is a trap inside the trap. Python 3.12 changed the built-in sum() to use compensated (Neumaier) summation, so sum([4.50, 30.49, 15.01]) returns 50.0. Switching the loop to sum() makes this test pass without fixing anything: some other combination of prices will still land on the wrong side, and the same logic in JavaScript or Java stays broken. The real fix is to stop doing money arithmetic in floats. Store cents as integers (or use Decimal):
MAX_ITEMS = 10
FREE_SHIPPING_CENTS = 5000
def can_add(items_in_cart: int, qty: int) -> bool:
if qty < 1:
return False
return items_in_cart + qty <= MAX_ITEMS
def free_shipping(prices_cents: list[int]) -> bool:
return sum(prices_cents) >= FREE_SHIPPING_CENTSWith the tests updated to pass cents ([3049, 1950] and [450, 3049, 1501]), all three pass. The lesson for test design: when a boundary is reached through arithmetic, test it with inputs that reach it through arithmetic, not just with a literal 50.00 handed straight to the comparison.
Picking values when the edges multiply
Real rules have more than one limit, and the number of boundary cases grows quickly. A few habits keep it under control.
Test each edge of a range separately. An age field accepting 18 to 65 has two boundaries, so you want 17, 18, 65 and 66. Testing 18 and 65 only proves the inside works; the outside values prove the rejection works.
Decide how fine “just outside” is. For integers it’s 1. For money in cents it’s 1 cent. For timestamps it depends on the precision you store: if expiry is checked to the millisecond, test one millisecond before and after, not one minute. A test that checks a token at 14 and 16 minutes when the rule is 15 minutes leaves room for an off-by-a-minute bug.
Combine boundaries with equivalence partitions rather than with each other. You don’t need every boundary of every parameter crossed with every other one. Pick one boundary value per test and keep the other inputs at ordinary, clearly valid values, so a failure points at one edge.
Some textbooks describe a two-value version (on the boundary and just outside) and a three-value version (just inside, on, just outside). The extra inside value catches mistakes like == written where <= was meant, which a two-value test can miss, so the three-value version is usually worth the one extra case.
Interview practice
Usernames must be 3 to 20 characters long and may contain letters, digits and underscores. Which length values would you test, and is there anything about “characters” you’d ask the product owner?
Answer
For length, the boundaries are 3 and 20, so I’d test 2, 3, 4 and 19, 20, 21 characters, plus an empty string as the implicit lower edge. Each test would use otherwise valid characters (say, all lowercase letters) so that only the length varies, and the names would state the rule: “a 20-character username is accepted”, “a 21-character username is rejected”.
Then I’d ask what a character is, because the answer changes where the boundary sits. If usernames are strictly ASCII letters, digits and underscores, length is unambiguous. If “letters” includes accented or non-Latin letters, the code’s notion of length may differ from a person’s. In JavaScript, 'é'.length is 1 when the accent is precomposed and 2 when it’s a plain e followed by a combining accent, and an emoji like '😀' has a .length of 2 because it’s two UTF-16 code units. A 20-character name could be rejected as 21. I’d want the rule written down (code points after normalization, most likely) and boundary tests at 20 and 21 that include a multi-unit character, so the limit is tested in the unit the business actually means.