Software Testing · lesson 4 of 8
Beginner
1Intermediate
4Advanced
3Also part of Software Testing
12- + Testing Python Code from Python
- + Unit Testing with JUnit 5 and Mockito from Java
- + Testing JavaScript with Jest and Vitest from JavaScript
- + Testing in Go from Go
- + Testing in Rust from Rust
- + Testing with MUnit and ScalaCheck from Scala
- + Contract Testing with OpenAPI, and Running in CI from API Testing
- + Your First Playwright Test from Playwright
- + Flaky Tests: Staleness, Retries, and CI from Selenium
- + Testing Data Pipelines from Data Engineering
- + terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan from Terraform
- + Testing TypeScript from TypeScript
Boundary-Value Analysis: The Bug Equivalence Partitioning Misses
Test one value per equivalence class and a real off-by-one bug slips through. Test the exact edges instead and the same bug fails loudly.
By SkillByExample · Published
Testing one value from the middle of each equivalence class feels thorough — every class is represented, every test passes. But a boundary condition written with the wrong comparison operator can hide in plain sight if nothing ever tests the exact edge.
A shipping-tier function with equivalence classes
def shipping_tier(weight_kg):
"""Return the shipping cost tier for a package weight in kg."""
if weight_kg < 1:
return "letter"
elif weight_kg <= 5:
return "small"
elif weight_kg <= 20:
return "medium"
else:
return "large"
Four equivalence classes: letter, small, medium, large. One reasonable way to test
this is to pick a value from the middle of each class:
from shipping import shipping_tier
def test_letter():
assert shipping_tier(0.5) == "letter"
def test_small():
assert shipping_tier(3) == "small"
def test_medium():
assert shipping_tier(10) == "medium"
def test_large():
assert shipping_tier(30) == "large"
$ pytest test_shipping_middle.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 4 items
test_shipping_middle.py::test_letter PASSED [ 25%]
test_shipping_middle.py::test_small PASSED [ 50%]
test_shipping_middle.py::test_medium PASSED [ 75%]
test_shipping_middle.py::test_large PASSED [100%]
============================== 4 passed in 0.03s ==============================
All four pass. Every equivalence class has a green test. But the function has a real bug:
the spec says a package weighing exactly 1 kg should ship as "letter" (the boundary is
inclusive), and weight_kg < 1 silently excludes it — a classic off-by-one from using <
instead of <=. None of the four middle-of-the-class values (0.5, 3, 10, 30) ever
touches 1, so nothing here can catch it.
The same bug, tested at the boundaries
Boundary-value analysis replaces “one value per class” with “the values right at each edge”:
from shipping import shipping_tier
def test_letter_at_exactly_1kg():
assert shipping_tier(1) == "letter"
def test_small_just_above_letter_boundary():
assert shipping_tier(1.01) == "small"
def test_small_at_exactly_5kg():
assert shipping_tier(5) == "small"
def test_medium_at_exactly_20kg():
assert shipping_tier(20) == "medium"
def test_large_just_above_medium_boundary():
assert shipping_tier(20.01) == "large"
$ pytest test_shipping_boundary.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 5 items
test_shipping_boundary.py::test_letter_at_exactly_1kg FAILED [ 20%]
test_shipping_boundary.py::test_small_just_above_letter_boundary PASSED [ 40%]
test_shipping_boundary.py::test_small_at_exactly_5kg PASSED [ 60%]
test_shipping_boundary.py::test_medium_at_exactly_20kg PASSED [ 80%]
test_shipping_boundary.py::test_large_just_above_medium_boundary PASSED [100%]
================================== FAILURES ===================================
_________________________ test_letter_at_exactly_1kg __________________________
def test_letter_at_exactly_1kg():
> assert shipping_tier(1) == "letter"
E AssertionError: assert 'small' == 'letter'
E
E - letter
E + small
test_shipping_boundary.py:4: AssertionError
=========================== short test summary info ===========================
FAILED test_shipping_boundary.py::test_letter_at_exactly_1kg - AssertionError...
========================= 1 failed, 4 passed in 0.06s ==========================
shipping_tier(1) returns "small", not "letter" — the exact bug the middle-value suite
never had a chance to see. The other four boundary tests pass because those specific edges
(5, 20, and just above each) happen to use the correct <= comparison; only the
weight_kg < 1 boundary was written wrong. This is the core case for boundary-value
analysis: a bug that lives on a single edge is invisible to any test that doesn’t stand
exactly on that edge.
Fixing it and re-running both suites
def shipping_tier(weight_kg):
if weight_kg <= 1: # fixed: was `< 1`
return "letter"
elif weight_kg <= 5:
return "small"
elif weight_kg <= 20:
return "medium"
else:
return "large"
$ pytest test_shipping_boundary.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 5 items
test_shipping_boundary.py::test_letter_at_exactly_1kg PASSED [ 20%]
test_shipping_boundary.py::test_small_just_above_letter_boundary PASSED [ 40%]
test_shipping_boundary.py::test_small_at_exactly_5kg PASSED [ 60%]
test_shipping_boundary.py::test_medium_at_exactly_20kg PASSED [ 80%]
test_shipping_boundary.py::test_large_just_above_medium_boundary PASSED [100%]
============================== 5 passed in 0.03s ==============================
All five boundary tests now pass, and the middle-value suite would have kept passing the
whole time regardless — it never had the power to tell the two versions of the function
apart. That’s the practical rule: keep the equivalence-partitioning tests for basic coverage
of each class, but treat the boundary pair around every <, <=, >, or >= in the code
as the tests that actually earn their place.
Practice
-
Add the missing high boundary. The function has no explicit upper limit — very large weights all fall into
"large". Is there a boundary worth testing there? Write a test (or explain why none is needed). -
Find the second bug. Change
elif weight_kg <= 5toelif weight_kg < 5and predict which boundary test now fails. Run the boundary suite to check your prediction. -
Grading-scale boundaries. Given
def grade(score): return "A" if score >= 90 else "B" if score >= 80 else "C", write boundary tests for89,90,79, and80. Which pair of values would catch a>vs>=typo on the90boundary? -
Equivalence classes for a different function.
def discount(quantity): return 0.20 if quantity >= 100 else 0.10 if quantity >= 10 else 0.0has three equivalence classes. List the boundary values you’d test, then write the tests.
Solution 1
def test_large_at_very_high_weight():
assert shipping_tier(1_000_000) == "large"
There’s no upper numeric boundary in the code (no elif weight_kg <= X above "large"),
so there’s no edge to stand on — any value above 20 behaves identically. A single
representative value for the open-ended "large" class is enough; boundary-value analysis
only pays off where a boundary condition actually exists in the code.
Solution 2
$ pytest test_shipping_boundary.py -v
============================= test session starts =============================
platform linux -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/testing
collected 5 items
test_shipping_boundary.py::test_letter_at_exactly_1kg PASSED [ 20%]
test_shipping_boundary.py::test_small_just_above_letter_boundary PASSED [ 40%]
test_shipping_boundary.py::test_small_at_exactly_5kg FAILED [ 60%]
test_shipping_boundary.py::test_medium_at_exactly_20kg PASSED [ 80%]
test_shipping_boundary.py::test_large_just_above_medium_boundary PASSED [100%]
================================== FAILURES ===================================
__________________________ test_small_at_exactly_5kg __________________________
def test_small_at_exactly_5kg():
> assert shipping_tier(5) == "small"
E AssertionError: assert 'medium' == 'small'
E
E - small
E + medium
test_shipping_boundary.py:10: AssertionError
=========================== short test summary info ===========================
FAILED test_shipping_boundary.py::test_small_at_exactly_5kg - AssertionError...
========================= 1 failed, 4 passed in 0.07s ==========================
test_small_at_exactly_5kg fails: shipping_tier(5) now falls through to "medium" because
5 < 5 is False. This is the same shape of bug as the original one, just on the next
boundary down the chain — confirming that each </<= boundary needs its own dedicated
test; fixing one doesn’t protect the others.
Solution 3
def test_b_at_79():
assert grade(79) == "C"
def test_b_at_80():
assert grade(80) == "B"
def test_a_at_89():
assert grade(89) == "B"
def test_a_at_90():
assert grade(90) == "A"
89 and 90 are the pair that would catch a > vs >= typo on the 90 boundary: with
score > 90 instead of score >= 90, grade(90) would wrongly return "B" instead of
"A", and test_a_at_90 would fail while test_a_at_89 still passes.
Solution 4
Three classes: quantity < 10 (no discount), 10 <= quantity < 100 (10%), quantity >= 100
(20%). Boundary values: 9 and 10 (the first boundary), 99 and 100 (the second).
def test_no_discount_at_9():
assert discount(9) == 0.0
def test_ten_percent_at_10():
assert discount(10) == 0.10
def test_ten_percent_at_99():
assert discount(99) == 0.10
def test_twenty_percent_at_100():
assert discount(100) == 0.20