Skip to main content
Software Testing · lesson 4 of 8
Software Testing intermediate Lesson 4 of 8

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 boundary-value-analysis equivalence-partitioning pytest

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

  1. 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).

  2. Find the second bug. Change elif weight_kg <= 5 to elif weight_kg < 5 and predict which boundary test now fails. Run the boundary suite to check your prediction.

  3. Grading-scale boundaries. Given def grade(score): return "A" if score >= 90 else "B" if score >= 80 else "C", write boundary tests for 89, 90, 79, and 80. Which pair of values would catch a > vs >= typo on the 90 boundary?

  4. Equivalence classes for a different function. def discount(quantity): return 0.20 if quantity >= 100 else 0.10 if quantity >= 10 else 0.0 has 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

Frequently Asked Questions

What is equivalence partitioning?
It's the practice of grouping a function's possible inputs into classes that should all behave the same way, then testing one representative value per class instead of every possible input. It's a good starting point for keeping a test suite small, but picking values from the middle of each class — as this lesson shows — can miss bugs that live exactly at the class boundaries.
What is boundary-value analysis?
A testing technique that specifically targets the edges of each equivalence class — the smallest and largest values still inside a range, plus the values just outside it. Most off-by-one bugs (using < instead of <=, or vice versa) only manifest at these exact boundary values, which is why boundary tests catch bugs that middle-of-the-range tests miss.
Do I need both equivalence partitioning and boundary-value analysis?
Yes — they answer different questions. Equivalence partitioning tells you which classes exist so you don't test redundant values from the same class over and over. Boundary-value analysis tells you which specific values within those classes are most likely to expose a bug. Use partitioning to decide what to test, and boundary values to decide exactly which numbers.
How many boundary values should I test per boundary?
Two is the practical minimum: the boundary value itself and the value immediately on the other side of it (e.g. for a `<= 5` boundary, test both `5` and `5.01` or `6`, whichever is the next representable value for that type). Some teams also test one value clearly inside each class to confirm the partitioning assumption, but the boundary pair is what actually catches off-by-one bugs.