Module 5: Coverage and Edge Cases

Boundary Value Analysis and Property-Based Testing with Hypothesis

Boundary Value Analysis and Property-Based Testing with Hypothesis

Capsule overview

You know how to measure coverage and use prompts to discover edge cases. But there are two techniques that multiply your effectiveness: boundary value analysis (systematic, predictable) and property-based testing with Hypothesis (random, discovers what you didn't imagine). This capsule teaches you both: when to use explicit limit values, when to delegate to mathematical properties, and how to combine it all with Claude Code.

By the end you'll have an arsenal of three complementary approaches: boundary testing for known limits, property testing for invariant properties, and prompts so Claude Code thinks for you about difficult scenarios.


Boundary Value Analysis

Bugs cluster at the limits

An empirical observation in testing: most bugs appear at the limits, not in the "middle" of the domain. If a function accepts an age between 0 and 120, the bug is almost certainly at -1, 0, 1, 119, 120 or 121 — not at 50, 30 or 80.

Type of boundaryExampleWhy the code fails
Minimum0Off-by-one: if age < 0 vs if age <= 0
Maximum120The same at the other end
Zero0Division, indices, special logic
One more/less-1, 121Just outside the valid range
Empty/full[], [1], a very long listExtreme length

A practical rule: test the limits, not the center

For a function that validates an age of 0–120:

✅ Test: -1, 0, 1, 119, 120, 121
❌ Don't waste time with: 50, 30, 80 — middle values rarely reveal bugs.

For a function on lists:

✅ Test: [], [1], [1, 2], a very long list (1000+ elements)
❌ Don't prioritize: [1, 2, 3, 4, 5] — an arbitrary intermediate length is almost never the problem.

Example: validate_age

def validate_age(age: int) -> bool:
    """Returns True if age is in [0, 120]."""
    return 0 <= age <= 120

A naive test would only try validate_age(25) and it would pass. The boundary test covers all the critical points:

import pytest


def validate_age(age: int) -> bool:
    """Returns True if age is in [0, 120]."""
    return 0 <= age <= 120


@pytest.mark.parametrize("age,expected", [
    (-1, False),    # Below the minimum
    (0, True),      # The minimum limit
    (1, True),      # Just above the minimum
    (119, True),    # Just below the maximum
    (120, True),    # The maximum limit
    (121, False),   # Above the maximum
])
def test_validate_age_boundaries(age, expected):
    assert validate_age(age) == expected

Run it:

pytest test_validate_age.py -v

Why boundaries reveal bugs: a real example

Imagine a developer mistakenly wrote if age < 1 instead of if age < 0, thinking "a negative age is impossible, no need to test it." Or that they used if age <= 119 when the limit should be 120. A test with validate_age(25) would pass. But a boundary test with 0 or 120 would fail immediately. That's the power: the limits are where the code makes different decisions, and a wrong decision there propagates the error across the whole domain.

A real bug: off-by-one at the limits

Imagine someone implemented validate_age with a common error:

def validate_age(age: int) -> bool:
    return 0 < age < 120  # Bug: uses < instead of <=

A test with validate_age(25) would pass. But the boundary test with (0, True) would fail: the function returns False for age 0 when it should accept it. That's exactly the kind of bug boundary testing catches. Without explicit boundaries, the bug would reach production and users aged 0 or 120 would be incorrectly rejected.


Boundary testing patterns

The parametrized structure

@pytest.mark.parametrize is ideal for boundaries: one test, many values.

@pytest.mark.parametrize("value,expected", [
    (min_value - 1, False),   # Below the range
    (min_value, True),        # The lower limit
    (min_value + 1, True),    # Just inside
    (max_value - 1, True),    # Just inside
    (max_value, True),        # The upper limit
    (max_value + 1, False),   # Above the range
])
def test_boundaries(value, expected):
    assert function_under_test(value) == expected

Lists: empty, one, many

@pytest.mark.parametrize("items", [
    [],           # Empty
    [1],          # One element
    [1, 2],       # Two elements (some implementations fail here)
    list(range(10000)),  # Many (stack, memory, a hidden O(n²))
])
def test_list_operations(items):
    result = process_list(items)
    assert result is not None  # or whatever property applies

Strings: empty, one character, Unicode, very long

@pytest.mark.parametrize("s", [
    "",
    "a",
    "ñ",
    "🔥",           # Unicode
    "a" * 1_000_000,
])
def test_string_handling(s):
    result = normalize(s)
    assert isinstance(result, str)

A real bug: off-by-one at the limits

Imagine someone implements validate_age like this:

def validate_age(age: int) -> bool:
    """Returns True if age is in [0, 120]."""
    if age < 0:
        return False
    if age > 120:  # Bug: it should be >= 121 or keep > 120
        return False
    return True

That version is correct. But this variation has a typical bug:

def validate_age_buggy(age: int) -> bool:
    """A buggy version: uses < instead of <= at the maximum."""
    if age < 0:
        return False
    if age >= 120:   # Bug: 120 should be valid, but it rejects it
        return False
    return True

In fact the bug would be on the other side: age > 120 lets 120 through, but age >= 120 rejects 120. The boundary test with 120 would detect it. Without boundary tests, validate_age(25) passes and you think everything is fine. In production, users who are 120 years old (or badly formatted data) would fail.

The lesson: A single test with a "normal" value (25, 50, 80) wouldn't have found the bug. The six boundary values would.


An introduction to property-based testing

From examples to properties

In typical tests you define concrete examples:

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

In property-based testing you define invariant properties that must hold for any valid input:

  • "For any integer n, abs(n) >= 0"
  • "For any string s, reverse(reverse(s)) == s"
  • "For any list lst, len(sorted(lst)) == len(lst)"

The framework generates hundreds (or thousands) of random inputs and verifies that the property holds. It finds edge cases you'd never have written by hand.

From a mathematical hypothesis → to testing

The idea comes from QuickCheck (Haskell): if you can state a mathematical property, the computer can look for counterexamples. Hypothesis is the most widely used implementation in Python.


Hypothesis: the fundamentals

Installation

pip install hypothesis

Or with Poetry/uv:

poetry add --group dev hypothesis
uv add hypothesis --dev

The first example: absolute value

from hypothesis import given
from hypothesis import strategies as st


@given(st.integers())
def test_absolute_value_always_positive(n):
    result = abs(n)
    assert result >= 0

Hypothesis will generate many integers (positive, negative, zero, very large) and verify that abs(n) >= 0 always holds.

The second example: reverse twice

def reverse(s: str) -> str:
    return s[::-1]


@given(st.text())
def test_reverse_twice_returns_original(s):
    assert reverse(reverse(s)) == s

The property: reversing twice gives back the original. Hypothesis tests with empty strings, Unicode, emojis, etc.

The third example: sort preserves length

@given(st.lists(st.integers()))
def test_sort_preserves_length(lst):
    assert len(sorted(lst)) == len(lst)

The property: sorting doesn't change the number of elements.

Why property-based testing finds bugs you don't see

With example-based tests, you choose 3-5 inputs. With property-based testing, Hypothesis can generate 100+ in a single run. And it does it intelligently: it tends to try "interesting" values (0, -1, empty strings, very long lists) more often than purely random values. That means in a few runs you're already covering many edge cases a human wouldn't have prioritized.

A real example: a function that parses dates like "YYYY-MM-DD". An example-based test would try "2024-01-15". But Hypothesis could generate "2024-02-30" (an invalid date) or "0000-00-00" — and discover that your parser doesn't validate correctly.


Common Hypothesis strategies

Basic types

st.integers()           # Any int (includes negatives, zero, large)
st.floats()             # Floats (includes NaN, Inf by default)
st.text()               # Unicode strings
st.booleans()           # True/False
st.none()               # None

With constraints

st.integers(min_value=0, max_value=100)
st.floats(min_value=0.01, max_value=10000, allow_nan=False)
st.text(min_size=1, max_size=50)
st.text(alphabet=st.characters(whitelist_categories=("L", "N")))  # Only letters and numbers

Composite structures

st.lists(st.integers())                    # A list of integers
st.lists(st.integers(), min_size=1)        # At least one element
st.dictionaries(st.text(), st.integers())  # Dict[str, int]
st.tuples(st.integers(), st.text())        # A tuple (int, str)
st.one_of(st.none(), st.integers())        # None or an int
st.sampled_from(["a", "b", "c"])           # One of these values

A combined example

@given(
    st.lists(st.floats(min_value=0.01, max_value=1000, allow_nan=False))
)
def test_sum_of_non_negative_floats(prices):
    total = sum(prices)
    assert total >= 0

Typical mathematical properties

When you don't know which concrete inputs to test, think about invariant properties:

PropertyExampleWhen to use it
Idempotencef(f(x)) == f(x)Normalization, sanitization
Commutativityf(a, b) == f(b, a)Addition, set union
Invertibilitydecode(encode(x)) == xSerialization, compression
Structure preservationlen(sort(lst)) == len(lst)Sorting, filtering
Lower/upper boundmin <= clamp(x, min, max) <= maxConstraint functions
Non-negativityabs(x) >= 0Absolute values, distances

If your function implements one of these ideas, Hypothesis can verify it automatically against many inputs.

Reproducing a Hypothesis failure

When Hypothesis finds a counterexample, it saves the seed to reproduce it. If the test fails, you'll see something like:

hypothesis.invalid.Example: @given(...)
Falsifying example: test_foo(x=0, y=-1)

To reproduce that specific case in future runs, use @reproduce_failure:

from hypothesis import assume, given, reproduce_failure
from hypothesis import strategies as st

@given(st.integers(), st.integers())
def test_something(a, b):
    assume(b != 0)
    assert (a // b) * b + (a % b) == a  # Not always true in Python

# If it fails, Hypothesis gives you something like:
# @reproduce_failure('6.0.0', b'AAEBAA==')
# Add that decorator temporarily to debug

In practice, you usually copy the example that failed and write an explicit unit test for that case, and then fix the code or the property.


A real example: a shopping cart

Let's say we have a simple ShoppingCart class:

"""A simple shopping cart implementation for property testing."""


class ShoppingCart:
    def __init__(self):
        self._items: list[tuple[str, float]] = []

    def add_item(self, name: str, price: float) -> None:
        if price < 0:
            raise ValueError("Price must be non-negative")
        self._items.append((name, price))

    def total(self) -> float:
        return sum(price for _, price in self._items)

    def item_count(self) -> int:
        return len(self._items)

The property: the total is the sum of the prices

import pytest
from hypothesis import given
from hypothesis import strategies as st

from shopping_cart import ShoppingCart


@given(
    st.lists(
        st.floats(min_value=0.01, max_value=10000, allow_nan=False),
        min_size=0,
    )
)
def test_cart_total_equals_sum_of_prices(prices):
    cart = ShoppingCart()
    for i, price in enumerate(prices):
        cart.add_item(f"item_{i}", price)
    assert cart.total() == pytest.approx(sum(prices))

pytest.approx handles floating-point precision errors. The property: "for any list of valid prices, the cart's total matches the sum".

The property: the number of items is the length of the added list

@given(
    st.lists(
        st.tuples(
            st.text(min_size=1),
            st.floats(min_value=0.01, max_value=10000, allow_nan=False),
        ),
        min_size=0,
    )
)
def test_cart_item_count_matches_added(items):
    cart = ShoppingCart()
    for name, price in items:
        cart.add_item(name, price)
    assert cart.item_count() == len(items)

When to use each approach

ApproachWhen to use it
Boundary testingYou know the exact limits: age 0–120, string max 100 chars, a non-empty list
Property testingYou know invariant properties but not every possible input
Edge case promptsYou want Claude Code to think of scenarios that don't occur to you

The three complement each other: boundaries for known limits, properties for mathematical invariants, prompts for creative exploration.

Typical properties you can verify

When you can't think of a property to test, these templates help:

PropertyExampleWhen it applies
Idempotencef(f(x)) == f(x)Normalization, sanitization
Commutativityf(a, b) == f(b, a)Addition, set union
Associativityf(f(a, b), c) == f(a, f(b, c))Mathematical operations
Size invariancelen(transform(lst)) == len(lst)Map, filter without discarding
Order preservedIf a < b then f(a) <= f(b)Monotonic functions
Round-tripdecode(encode(x)) == xSerialization, compression

Advanced Hypothesis configuration

Reproducing a failure

When Hypothesis finds a counterexample, it can show something like:

Falsifying example: test_sort_preserves_length(lst=[0, 0, -1])

To reproduce that same example on the next run, use @seed or save the example. Hypothesis uses an internal seed; if the test fails, it prints the seed at the end. You can force it:

from hypothesis import seed, given

@seed(123456789)
@given(st.lists(st.integers()))
def test_sort_preserves_length(lst):
    assert len(sorted(lst)) == len(lst)

More usefully: when it fails, Hypothesis usually prints something like @seed(12345). Add that decorator temporarily to debug the same example over and over.

Adjusting the number of examples and the time

By default Hypothesis generates about 100 examples per test. If your function is expensive or you want more confidence:

from hypothesis import settings, given
from hypothesis import strategies as st

@settings(max_examples=500)
@given(st.integers())
def test_more_examples(n):
    assert abs(n) >= 0

Or reduce it for very slow tests:

@settings(max_examples=20, deadline=500)  # 20 examples, 500ms per example
@given(st.lists(st.integers(), max_size=100))
def test_expensive_operation(lst):
    ...

Claude Code + Hypothesis

A prompt to generate property-based tests

You can ask Claude Code to generate tests with Hypothesis:

Generate property-based tests with Hypothesis for this function. Define the properties that must always be true, choose the right strategies and make sure the code is runnable from the imports.

An example prompt and result

Your prompt:

I have this function that normalizes prices (rounds to 2 decimals, rejects negatives). Generate 3 property-based tests with Hypothesis that verify invariant properties.

The code under test:

def normalize_price(price: float) -> float:
    if price < 0:
        raise ValueError("Price cannot be negative")
    return round(price, 2)

A typical Claude Code result:

from hypothesis import given
from hypothesis import strategies as st


@given(st.floats(min_value=0, allow_nan=False))
def test_normalize_price_non_negative(price):
    result = normalize_price(price)
    assert result >= 0


@given(st.floats(min_value=0, allow_nan=False))
def test_normalize_price_at_most_two_decimals(price):
    result = normalize_price(price)
    s = str(result)
    if "." in s:
        decimal_places = len(s.split(".")[1])
        assert decimal_places <= 2
    else:
        assert True


@given(st.floats(min_value=0, allow_nan=False))
def test_normalize_price_idempotent(price):
    first = normalize_price(price)
    second = normalize_price(first)
    assert first == second

Claude Code can define properties you wouldn't have considered (for example, idempotence) and choose appropriate strategies.


Exercises

Exercise 1: Boundary tests for a percentage range

Write parametrized tests for a validate_discount(percent: float) -> bool function that returns True if 0 <= percent <= 100. Cover all the boundaries: below, the minimum, just above the minimum, just below the maximum, the maximum, above.

See solution
import pytest


def validate_discount(percent: float) -> bool:
    return 0 <= percent <= 100


@pytest.mark.parametrize("percent,expected", [
    (-0.01, False),   # Below the minimum
    (0.0, True),      # The minimum
    (0.01, True),     # Just above the minimum
    (99.99, True),    # Just below the maximum
    (100.0, True),    # The maximum
    (100.01, False),  # Above the maximum
])
def test_validate_discount_boundaries(percent, expected):
    assert validate_discount(percent) == expected

Exercise 2: A property test for addition

The add(a, b) function adds two numbers. Write a property-based test with Hypothesis that verifies: for any pair of integers (a, b), add(a, b) == add(b, a) (commutativity).

See solution
from hypothesis import given
from hypothesis import strategies as st


def add(a: int, b: int) -> int:
    return a + b


@given(st.integers(), st.integers())
def test_add_commutative(a, b):
    assert add(a, b) == add(b, a)

Exercise 3: A strategy for a non-empty list

You need a strategy that generates lists of strings with at least one element. Write the strategy and a property test that verifies: "for any non-empty list of strings, the concatenation of its elements has length >= 1".

See solution
from hypothesis import given
from hypothesis import strategies as st


@given(st.lists(st.text(), min_size=1))
def test_concatenation_non_empty(lst):
    concat = "".join(lst)
    # If the list isn't empty, at least one string can contribute length
    # But a string can be "" — so the safer property is:
    assert len(lst) >= 1
    # Or if we know we're using min_size=1 and text(min_size=1):
    # assert len(concat) >= 1

A stricter version (non-empty strings):

@given(st.lists(st.text(min_size=1), min_size=1))
def test_concatenation_non_empty_strict(lst):
    concat = "".join(lst)
    assert len(concat) >= 1

Exercise 4: A cart with prices that can be zero

Modify the cart test to allow prices of exactly 0 (free products). What strategy would you use? Does add_item's implementation need adjusting?

See solution

If add_item rejects price < 0, then price == 0 is valid. Just change the strategy:

@given(
    st.lists(
        st.floats(min_value=0, max_value=10000, allow_nan=False),  # 0 included
        min_size=0,
    )
)
def test_cart_total_with_zero_prices(prices):
    cart = ShoppingCart()
    for i, price in enumerate(prices):
        cart.add_item(f"item_{i}", price)
    assert cart.total() == pytest.approx(sum(prices))

If the current implementation requires price > 0, you'd have to relax it to accept 0 or use min_value=0.01 in the tests so they pass without changing the code. The exercise assumes you accept 0; in that case, min_value=0 and the implementation must allow it.

Exercise 5: Boundary + property for clamp

Implement clamp(value, low, high) that returns value if it's in [low, high], or low/high if it's outside. Write:

  1. Boundary tests with parametrize for the limit values.
  2. A property test with Hypothesis: "the result is always in [low, high]".
See solution
from hypothesis import assume, given
from hypothesis import strategies as st
import pytest


def clamp(value: float, low: float, high: float) -> float:
    if value < low:
        return low
    if value > high:
        return high
    return value


@pytest.mark.parametrize("value,low,high,expected", [
    (-1, 0, 10, 0),
    (0, 0, 10, 0),
    (1, 0, 10, 1),
    (9, 0, 10, 9),
    (10, 0, 10, 10),
    (11, 0, 10, 10),
])
def test_clamp_boundaries(value, low, high, expected):
    assert clamp(value, low, high) == expected


@given(
    value=st.floats(allow_nan=False),
    low=st.floats(allow_nan=False),
    high=st.floats(allow_nan=False),
)
def test_clamp_always_in_range(value, low, high):
    assume(low <= high)  # Hypothesis: assume to filter out invalid inputs
    result = clamp(value, low, high)
    assert low <= result <= high

Exercise 6: A prompt to Claude Code

Take a function of your own (or the clamp one from the previous exercise) and write a prompt for Claude Code asking for: (a) parametrized boundary tests, (b) two property-based tests with Hypothesis. Copy the prompt and describe which properties you'd suggest if Claude Code asks you for help.

See solution

An example prompt:

I have this function:

def clamp(value: float, low: float, high: float) -> float:
    if value < low: return low
    if value > high: return high
    return value

I need:

  1. Boundary tests with pytest.mark.parametrize covering value below low, at low, between low and high, at high, and above high.
  2. Two property-based tests with Hypothesis. The properties I want to verify: (a) the result is always in [low, high], (b) if value is already in [low, high], the result is value.

Generate the complete code with imports.

Properties you could suggest:

  • The result is always between low and high (inclusive).
  • If low <= value <= high, then clamp(value, low, high) == value.
  • clamp is idempotent: clamp(clamp(v,l,h), l, h) == clamp(v,l,h).

Troubleshooting

Hypothesis finds a counterexample but I don't know which one

Cause: Hypothesis saves an example that failed and uses it in the next run, but it may not print it clearly.

Solution: Run it with more verbosity: pytest -v -s. Hypothesis usually shows the example that failed. You can also use @given(...) with a temporary print or hypothesis.settings(verbosity=2) to see what's being generated.

Flaky or tests that sometimes pass and sometimes don't

Cause: The function under test or the property has non-deterministic behavior, or the strategy generates "problematic" values (NaN, Inf, very large) that you don't handle.

Solution: Restrict the strategies: allow_nan=False, bounded min_value/max_value. Use hypothesis.assume() to filter inputs that don't meet the preconditions (for example, assume(low <= high)).

DeadlineExceeded — the test takes too long

Cause: Hypothesis runs many iterations and your function is slow, or the strategy generates very large structures.

Solution: Limit the size: st.lists(..., max_size=100), st.text(max_size=500). Or disable the deadline: @settings(deadline=None) (only for justified cases).

The property test passes but an explicit boundary fails

Cause: The strategy isn't generating the boundary (for example, 0 or 121 in a very wide range) with a high probability in few runs.

Solution: Don't rely on property testing alone for known boundaries. Use explicit boundary tests with parametrize for the limits that matter.

ImportError or Hypothesis isn't installed

Cause: Hypothesis isn't in the execution environment.

Solution: pip install hypothesis or poetry add hypothesis --group dev. Verify that the IDE's environment and the terminal's are the same (which python, poetry env info).


Connection with the module's project

The module's project is A suite with 90%+ coverage. Boundary value analysis and property-based testing are optional but highly recommended tools:

  1. Coverage tells you where the gaps are — boundary tests help you cover conditional branches at the limits (if x < 0, if x > max, etc.).
  2. Property-based testing discovers inputs you'd never write by hand and that could expose bugs in mathematical functions, validators or transformations.
  3. Claude Code can generate both types of tests if you give it the code and a clear prompt.

A suggested flow: measure coverage → identify functions with logic at the limits → ask Claude Code for boundary tests → add 1–2 property tests for functions with clear properties (sort, sum, normalization) → measure again.


Summary

  • ✅ Bugs cluster at boundaries: minimum, maximum, zero, one more/less, empty/full.
  • ✅ Use @pytest.mark.parametrize for systematic boundary tests at known limits.
  • ✅ Property-based testing with Hypothesis: you define invariant properties and the framework generates many inputs.
  • ✅ Common strategies: st.integers(), st.text(), st.lists(), st.floats() with min_value/max_value.
  • ✅ Boundary testing for known limits; property testing for properties; prompts for exploration with Claude Code.
  • ✅ You can ask Claude Code to generate boundary and property tests by giving it the code and clear requirements.

Next capsule: Project — A suite with 90%+ coverage. You'll apply everything from the module to bring existing code up to ≥90% coverage.


Additional Resources

  1. Hypothesis Documentation — Official Hypothesis documentation
  2. Hypothesis Strategies — A catalog of strategies
  3. Property-Based Testing with Hypothesis — An article on property-based testing
  4. QuickCheck: The Original — The origin of the approach (Haskell)
  5. Boundary Value Analysis — Software Testing Fundamentals — An introduction to boundary testing
  6. Test-Driven Development with Property-Based Testing — Properties + TDD

Module 5, Capsule 05 — Testing with Claude Code Guide