Test Error Paths and Edge Cases — Hands-On Software Testing, Part 8
- Shawn West
- Jul 8
- 4 min read
Updated: Jul 28
Hands-On Software Testing · Part 8
The happy path is the easy part of testing and the part that rarely breaks in production. The bugs that actually reach users live in the cases nobody tested: empty input, the off-by-one boundary, the request that arrives twice. This walks through systematically hunting the edge and error cases for a function — and turning the boundary categories themselves into a reusable checklist you can apply to everything you write next.
Most production bugs aren't in the happy path. They're in edges — empty inputs, boundary values, unusual combinations. This tutorial walks through finding and testing them systematically.
What You'll Build
A test file with comprehensive edge case coverage for one function, following a deliberate process.
Step 1: Pick the Function (5 min)
Same function as previous tutorials, or any function with branches. We'll use calculate_discount(price, tier):
def calculate_discount(price, tier):
if price <= 0:
return 0
if tier == "premium":
return price * 0.10
if tier == "gold":
return price * 0.20
return 0
Step 2: Walk the Boundary Categories (15 min)
For each input, ask:
Numeric (price):
Zero
Negative
Very large
Decimal precision (e.g., 0.01)
Maximum representable
String (tier):
Empty string
Whitespace
Different case (PREMIUM, Premium)
Unicode characters
Very long string
Type (any):
None / null / undefined
Wrong type (number where string expected)
Don't write tests for every theoretical edge. Write tests for edges that could plausibly appear in real usage.
Step 3: Write the Edge Tests (20 min)
import pytest
class TestEdgeCases:
# Price boundary
def test_zero_price_returns_zero(self):
assert calculate_discount(0, "premium") == 0
def test_negative_price_returns_zero(self):
assert calculate_discount(-100, "premium") == 0
def test_decimal_precision(self):
# 9.99 * 0.10 = 0.999; how do we round?
result = calculate_discount(9.99, "premium")
assert result == 0.999 # Or whatever the spec says
def test_very_large_price(self):
result = calculate_discount(10_000_000, "premium")
assert result == 1_000_000
# Tier handling
def test_empty_tier(self):
assert calculate_discount(100, "") == 0
def test_unknown_tier(self):
assert calculate_discount(100, "platinum") == 0
def test_case_sensitive_tier(self):
# Is "PREMIUM" the same as "premium"?
result = calculate_discount(100, "PREMIUM")
# Document expected behavior. Most likely: 0
assert result == 0
# Type handling
def test_none_price_raises(self):
with pytest.raises(TypeError):
calculate_discount(None, "premium")
def test_string_price_raises(self):
with pytest.raises(TypeError):
calculate_discount("100", "premium")
def test_none_tier(self):
# Common edge case — passing None as tier
result = calculate_discount(100, None)
assert result == 0
Each test exercises one specific edge.
Step 4: Find Bugs (5 min)
Running these tests, you'll likely discover:
A test that fails because the function actually returns -10 for negative price (the function had a < instead of <=)
Or: a test that revealed unclear behavior — what should happen for None tier?
Both are valuable. The first is a bug; fix the function. The second is missing specification; clarify it.
Step 5: Apply the Same Pattern to Other Functions (varies)
For each function with branches, walk:
Each input's boundaries
Each branch's edge
Each implicit assumption
Often a 30-minute review of a function produces 5-10 edge tests, several of which catch real bugs.
Step 6: API Edge Cases (15 min)
For API endpoints, common edges:
class TestCreateUserEdgeCases:
def test_empty_email(self, auth_token):
response = client.post(
"/api/users",
json={"name": "Sam", "email": ""},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 400
def test_email_with_no_at_sign(self, auth_token):
response = client.post(
"/api/users",
json={"name": "Sam", "email": "sam.example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 400
def test_extra_fields(self, auth_token):
response = client.post(
"/api/users",
json={
"name": "Sam",
"email": "sam@example.com",
"role": "admin", # User tries to escalate
"is_admin": True,
},
headers={"Authorization": f"Bearer {auth_token}"}
)
# Extra fields should be ignored or rejected
# User shouldn't become admin
user_id = response.json().get("id")
if user_id:
user = db_session.query(User).filter_by(id=user_id).first()
assert user.role == "standard" # Not admin
The "extra fields" test catches a security bug class. Worth testing.
Step 7: State Transition Edges (10 min)
For stateful systems, test transitions:
def test_delete_then_create_with_same_email(db_session, auth_token):
response1 = client.post(
"/api/users",
json={"name": "First", "email": "test@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
user_id = response1.json()["id"]
client.delete(f"/api/users/{user_id}",
headers={"Authorization": f"Bearer {auth_token}"}
)
response2 = client.post(
"/api/users",
json={"name": "Second", "email": "test@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
)
# Document expected: should this succeed or fail? Test the actual behavior.
assert response2.status_code in [201, 409]
Step 8: Concurrency Edges (10 min)
Real users do things in parallel:
def test_concurrent_creates_with_same_email(auth_token):
import threading
results = []
def create():
results.append(client.post(
"/api/users",
json={"name": "User", "email": "race@example.com"},
headers={"Authorization": f"Bearer {auth_token}"}
))
threads = [threading.Thread(target=create) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
successes = [r for r in results if r.status_code == 201]
assert len(successes) == 1
Catches race conditions that sequential tests miss.
What You Just Did
You moved beyond happy-path testing. The function or endpoint is now exercised against the edges that real users (and attackers) will eventually find.
Common Failure Modes
Edge-case theater. Lots of edge tests, mostly trivial. Quality over quantity.
Tests that document the bug. Test passes because the bug behavior is "expected." Fix the bug; update the test.
Combinatorial explosion. Testing every combination of every edge. Pick the edges that matter.
Edges revealed by tests are ignored. Tests reveal unclear behavior; team doesn't decide. Decide.
Continue the Hands-On Software Testing path
Previous — Part 5: Set Up CI to Run Tests
Next — Part 9: Debug a Failing Test
Part of the Hands-On Software Testing learning path.


