Module 4 · Software Engineering with Python
Edge cases and boundaries
Design tests around boundaries, where most bugs live, use pytest's parametrize to check many cases at once, and find and fix an off-by-one bug in Tallybook's late fees.
About 15 minutes
The problem
Tallybook charges a late fee on overdue invoices: 2% of what's outstanding for each full 30 days late, for at most 3 periods. A customer who paid exactly 30 days late was charged nothing. Another, 120 days late, was charged 6%, as expected. Support couldn't tell whether the first case was a bug or a misreading of the rule.
Bugs cluster at boundaries: the exact point where a rule changes. Tests that only check comfortable middle values miss them.
The concept
Boundary value testing
For every rule with a threshold, test just below, exactly at, and just above each boundary, plus the extremes:
| Days late | Expected periods | Why |
|---|---|---|
| 0 | 0 | on time |
| 29 | 0 | just under one full period |
| 30 | 1 | exactly one full period |
| 31 | 1 | just over |
| 60 | 2 | exactly two |
| 90 | 3 | exactly three |
| 120 | 3 | capped at three |
Parametrize
@pytest.mark.parametrize runs one test with many inputs, so a table like this becomes one short test, and each row is reported separately.
Fix with a failing test first
When you find a bug: first write a test that fails because of it, then fix the code, then watch the test pass. The test proves the bug existed and stays fixed.
Example
The late fee function as it was written, with boundary tests:
Shell (bash)
%%bash
cat > pytest.ini <<'EOF'
[pytest]
addopts = -q -p no:cacheprovider --tb=no -rf
console_output_style = classic
EOF
cat > fees.py <<'EOF'
from decimal import Decimal, ROUND_HALF_UP
LATE_FEE_RATE = Decimal("0.02")
PERIOD_DAYS = 30
MAX_PERIODS = 3
def late_fee(outstanding, days_late):
"""Late fee in kobo: 2% of outstanding per full 30 days late, at most 3 periods."""
periods = min((days_late - 1) // PERIOD_DAYS, MAX_PERIODS)
periods = max(periods, 0)
fee = Decimal(outstanding) * LATE_FEE_RATE * periods
return int(fee.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
EOF
cat > test_fees.py <<'EOF'
import pytest
from fees import late_fee
@pytest.mark.parametrize("days_late, periods", [
(0, 0), (29, 0), (30, 1), (31, 1), (60, 2), (90, 3), (120, 3),
])
def test_late_fee_periods(days_late, periods):
# 2% of N10,000 (1,000,000 kobo) is 20,000 kobo per period
assert late_fee(1_000_000, days_late) == 20_000 * periods
EOF
python -m pytest..F.FF.
=========================== short test summary info ===========================
FAILED test_fees.py::test_late_fee_periods[30-1] - assert 0 == (20000 * 1)
FAILED test_fees.py::test_late_fee_periods[60-2] - assert 20000 == (20000 * 2)
FAILED test_fees.py::test_late_fee_periods[90-3] - assert 40000 == (20000 * 3)
3 failed, 4 passed in 0.01sThree boundaries fail: exactly 30, 60 and 90 days. The cause is days_late - 1: someone "fixed" an earlier problem by subtracting one, which shifted every boundary by a day. The rule says a full 30 days, so 30 days late is one period. Fix it and rerun:
Shell (bash)
%%bash
cat > pytest.ini <<'EOF'
[pytest]
addopts = -q -p no:cacheprovider --tb=no -rf
console_output_style = classic
EOF
cat > fees.py <<'EOF'
from decimal import Decimal, ROUND_HALF_UP
LATE_FEE_RATE = Decimal("0.02")
PERIOD_DAYS = 30
MAX_PERIODS = 3
def late_fee(outstanding, days_late):
"""Late fee in kobo: 2% of outstanding per full 30 days late, at most 3 periods."""
if days_late < 0:
raise ValueError("days_late can't be negative")
periods = min(days_late // PERIOD_DAYS, MAX_PERIODS)
fee = Decimal(outstanding) * LATE_FEE_RATE * periods
return int(fee.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
EOF
cat > test_fees.py <<'EOF'
import pytest
from fees import late_fee
@pytest.mark.parametrize("days_late, periods", [
(0, 0), (29, 0), (30, 1), (31, 1), (60, 2), (90, 3), (120, 3),
])
def test_late_fee_periods(days_late, periods):
# 2% of N10,000 (1,000,000 kobo) is 20,000 kobo per period
assert late_fee(1_000_000, days_late) == 20_000 * periods
def test_negative_days_is_rejected():
with pytest.raises(ValueError):
late_fee(1_000_000, -1)
EOF
python -m pytest........
8 passed in 0.01sAll eight pass, and the negative-days case, which the old code quietly treated as zero, is now an explicit error.
Walkthrough
- Run the cells. In the fixed version, what fee does a ₦10,000 balance get at 89 days? At 91?
- Add boundary cases for the discount limit in
invoice_total: 0, 20 and 21 per cent. - Why does each parametrized case appear as a separate result?
- Write boundary tests for a new rule (the task below).
Practice
Task
8 minTallybook adds a rule: invoices of ₦500,000 or more need a manager's approval. Write a parametrized pytest test for a function needs_approval(total_kobo) with cases just below, exactly at, and just above the boundary, plus zero.
Your work is checked for
- Uses pytest.mark.parametrize
- A test function using needs_approval
- The exact boundary in kobo (50,000,000)
- Just below the boundary (49,999,999)
- Just above the boundary (50,000,001)
- Zero
Check your understanding
Answer every question to check.