Testing Edge Cases
Chapter 5: Testing Edge Cases and Error Conditions
Most bugs hide at the edges. Your function works perfectly for typical inputs but crashes on empty lists, negative numbers, or None values. Testing edge cases—the boundary conditions and unusual inputs—separates reliable code from brittle code. This chapter teaches you to identify, test, and handle edge cases systematically.
What Are Edge Cases?
Edge cases are inputs at the extreme ends of acceptable ranges or unusual combinations that developers often forget to handle. They're called "edge" because they sit at the boundaries of normal operation.
Common Edge Cases:
- Empty collections: Empty lists, dictionaries, strings
- Boundary values: Zero, negative numbers, maximum integers
- None/null values: Missing or undefined data
- Type mismatches: String when expecting integer
- Special characters: Unicode, newlines, escape sequences
Why edge cases matter: Production systems encounter these constantly. Users submit empty forms. APIs return null. Files are empty. If you don't test these cases, users will find the bugs for you.
Boundary Value Testing
Boundary value analysis tests values at the edges of acceptable ranges. If a function accepts integers 1-100, test 0, 1, 100, and 101. Bugs cluster at boundaries.
The Rule of Boundaries: Test the minimum, minimum+1, maximum-1, and maximum. Also test just outside these ranges.
Notice the tests at 64, 65, and above. The boundary is 65—that's where behavior changes. That's where bugs hide.
Handling None and Empty Values
None values cause more bugs than almost anything else. APIs return None. Database queries return None. User input might be None. Test for it explicitly.
Empty vs None: Empty and None are different. An empty list [] is not the same as None. Empty means "container with nothing in it." None means "no container at all."
Defensive Programming: Check for None before using values. Decide what None means in your context—error or default value?
Empty String Edge Cases:
def format_greeting(name):
"""Create greeting message."""
if not name: # Catches None and empty string
return "Hello, stranger!"
return f"Hello, {name}!"
def test_greeting_with_none():
assert format_greeting(None) == "Hello, stranger!"
def test_greeting_with_empty_string():
assert format_greeting("") == "Hello, stranger!"
def test_greeting_with_whitespace():
# Does whitespace-only count as empty?
assert format_greeting(" ") == "Hello, !" # Might want to .strip()Testing Error Conditions
Good code fails gracefully. Test that your code handles errors appropriately—raises the right exceptions, returns error codes, logs properly.
Using pytest.raises:
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_divide_normal():
assert divide(10, 2) == 5Test Error Messages: Don't just check that an exception is raised. Verify the message is helpful. Good error messages help users fix problems.
Multiple Error Conditions:
def create_user(username, age):
if not username:
raise ValueError("Username required")
if age < 0:
raise ValueError("Age must be positive")
if age > 150:
raise ValueError("Invalid age")
return {"username": username, "age": age}
def test_username_required():
with pytest.raises(ValueError, match="Username required"):
create_user("", 25)
def test_negative_age():
with pytest.raises(ValueError, match="positive"):
create_user("alice", -5)
def test_unrealistic_age():
with pytest.raises(ValueError, match="Invalid"):
create_user("alice", 200)Type Edge Cases
Python is dynamically typed. Users can pass anything. What happens when someone passes a string to a function expecting an integer?
Decide Type Handling Strategy:
- Duck typing: "If it walks like a duck..." Try to use it, fail if it doesn't work
- Explicit validation: Check types upfront, raise errors for wrong types
- Coercion: Convert to expected type if possible
def calculate_total(prices):
"""Sum prices. What if prices isn't a list?"""
return sum(prices)
# Tests for different types
def test_with_list():
assert calculate_total([1, 2, 3]) == 6
def test_with_tuple():
# Duck typing: tuples work too
assert calculate_total((1, 2, 3)) == 6
def test_with_generator():
# Generators work
assert calculate_total(x for x in [1, 2, 3]) == 6
def test_with_none():
# This will fail - decide if that's acceptable
with pytest.raises(TypeError):
calculate_total(None)Special Character Edge Cases
Strings with newlines, quotes, Unicode, or special characters often break string processing. Test them explicitly.
Common String Edge Cases:
- Newlines:
"Hello\nWorld" - Tabs and whitespace:
" \t " - Quotes:
'O"Reilly'and"She said 'hi'" - Unicode:
"Café","北京", emoji - Very long strings: 10,000 characters
- Special characters:
"<script>alert('xss')</script>"
def sanitize_input(text):
"""Remove dangerous characters."""
if not text:
return ""
return text.replace("<", "<").replace(">", ">")
def test_sanitize_script_tags():
dirty = "<script>alert('xss')</script>"
clean = sanitize_input(dirty)
assert "<script>" not in clean
assert "<script>" in clean
def test_sanitize_unicode():
text = "Café北京"
assert sanitize_input(text) == text # Should preserve
def test_sanitize_empty():
assert sanitize_input("") == ""
def test_sanitize_none():
assert sanitize_input(None) == ""Systematic Edge Case Testing
Checklist for Edge Cases:
- Empty inputs:
[],{},"",None - Boundary values: 0, -1, MAX_INT, MIN_INT
- Type variations: What if int is passed as string?
- Error conditions: Invalid data, missing files, network failures
- Special characters: Unicode, newlines, escape sequences
- Large inputs: 1 million items, 10MB strings
- Concurrent access: What if called simultaneously?
Don't test every possible edge case—that's impossible. Identify the risky ones based on how your code is used.
Common Edge Case Patterns
Pattern 1: The Empty Input Check
Always test what happens with empty inputs. This catches a huge class of bugs.
def process_items(items):
if not items: # Handles None, [], "", etc.
return []
return [process_item(item) for item in items]Pattern 2: The Null Object Pattern
Instead of checking for None everywhere, return empty but valid objects.
def get_user_preferences(user_id):
prefs = database.get_preferences(user_id)
if prefs is None:
return {} # Empty dict, not None
return prefsCallers can safely use prefs.get('theme') without checking for None first.
Pattern 3: The Validation Guard
Validate inputs early and raise clear errors. Don't let bad data propagate through your system.
def transfer_money(from_account, to_account, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
if from_account == to_account:
raise ValueError("Cannot transfer to same account")
# Now proceed safelyPattern 4: The Type Coercion
When accepting flexible input types, convert to expected type explicitly.
def calculate_tax(price, rate):
# Accept strings or numbers
price = float(price)
rate = float(rate)
if rate < 0 or rate > 1:
raise ValueError("Rate must be between 0 and 1")
return price * rateThese common patterns prevent most edge case bugs in production systems. Apply them consistently throughout your codebase and your code becomes significantly more robust and reliable.
Course Recommendations
Advanced Python Testing
- Comprehensive edge case strategies
- Property-based testing for edge case discovery
- Real-world debugging techniques
- Enroll at paiml.com
Defensive Programming in Python
- Input validation patterns
- Error handling best practices
- Type checking strategies
- Enroll at paiml.com
Test-Driven Development Mastery
- TDD with edge cases
- Boundary value analysis
- Error-first development
- Enroll at paiml.com
Quiz
📝 Test Your Knowledge: Testing Edge Cases
Take this quiz to reinforce what you've learned in this chapter.