Module 5: Coverage and Edge Cases
Edge Case Discovery with Claude Code
Edge Case Discovery with Claude Code
Capsule overview
You wrote tests for your function. They cover the happy path. But bugs in production almost never come from the happy path — they come from the empty string you never imagined, from the None that "should never arrive", from the emoji in the middle of the username that breaks the parser. Edge cases are invisible to you because your brain has biases that AI doesn't have.
This capsule is the key to Module 5: this is where Claude Code shines. Not as a replacement for your thinking, but as an amplifier. You define the categories; Claude Code fills them with concrete cases you'd never have considered. You'll learn a systematic framework of 10 edge case categories, specific prompts to discover them, and how to evaluate and prioritize the ones that really matter.
Why Humans Overlook Edge Cases
Cognitive biases when testing
When you think about "what to test" for a function, your mind follows predictable patterns:
- Happy path bias: You design for success. The function "should receive valid data" — but what happens when it doesn't?
- Familiarity bias: You test with inputs you've seen before.
"https://example.com","user@email.com",42. What about the domain with Unicode characters? The 300-character email? - Normal path fixation: You think about the flow you wrote, not the flows you avoided writing.
AI doesn't have these biases. It can systematically generate complete categories of cases that your intuition dismisses as "that never happens" — and that in production do happen.
The "it'll never happen" trap
def get_username_from_email(email: str) -> str:
"""Extract username before @."""
return email.split("@")[0]
You'd think of: "john@example.com" → "john". Maybe "a@b.c" → "a".
An attacker or a user in an edge case would send:
""→[""][0]=""(valid?)"no-at-sign"→["no-at-sign"][0]="no-at-sign"(is that a valid username?)"@only-domain.com"→["", "only-domain.com"][0]="""user@with@two@ats.com"→["user", "with", "two", "ats.com"][0]="user"(correct?)
The function "works" for your mental case. It fails on edge cases you never considered.
Claude Code's role
Claude Code can:
- ✅ Walk systematically through categories of edge cases
- ✅ Not have the "that doesn't happen" bias
- ✅ Generate adversarial inputs you wouldn't imagine
- ✅ Document why each edge case matters
Your job: define the categories, filter what's worth it, and prioritize. Claude Code's: amplify your capacity for discovery.
The 10 Categories of Edge Cases
Use this framework as a checklist when you ask Claude Code to discover edge cases.
| Category | Examples |
|---|---|
| Empty/Null | "", None, [], {} |
| Boundary values | 0, -1, MAX_INT, MIN_INT |
| Type mismatches | int where a str is expected, float where an int is expected |
| Unicode/Special chars | emojis, accents, RTL text, null bytes |
| Very large inputs | a 10MB string, a list with 1M items |
| Very small inputs | a single character, a single-element list |
| Duplicate values | the same item twice, duplicate keys |
| Ordering | sorted, reverse sorted, a single element, already sorted |
| Concurrency | the same operation twice simultaneously |
| Format edge cases | trailing spaces, mixed upper/lowercase, special characters in paths |
Practical application
For a function that processes a list of IDs:
def deduplicate_ids(ids: list[int]) -> list[int]:
"""Remove duplicates preserving order."""
seen = set()
result = []
for i in ids:
if i not in seen:
seen.add(i)
result.append(i)
return result
Walking through the categories:
- Empty/Null:
[],[None](if the function accepted other types) - Boundary:
[0],[sys.maxsize],[-1] - Type mismatches:
["123", 123]if the signature were more flexible - Ordering:
[3, 2, 1],[1, 1, 1],[1] - Duplicate values:
[1, 2, 1, 2],[1, 1, 1, 1]
Claude Code can expand each category with concrete cases for your specific function.
Prompts to Discover Edge Cases
1. Generic
What edge cases am I not testing for this function?
A typical result: A broad but somewhat generic answer. Useful as a starting point.
2. Systematic
For this function, generate edge cases in these categories:
empty, boundary, types, unicode, large inputs.
Include an example input and the expected behavior for each one.
A typical result: A list organized by category. It covers the framework of 10 categories explicitly.
3. Adversarial
Try to break this function with unexpected inputs.
Generate at least 10 inputs that could cause errors or undefined behavior.
A typical result: Focused on failures. Useful for finding bugs before they reach production.
4. Security
What malicious inputs could cause unexpected behavior?
Think about injection, overflow, control characters, etc.
A typical result: Security cases — path traversal, XSS, SQL injection, etc. Important for APIs and processing user data.
A practical workflow: from the function to the edge case suite
An effective flow when you work with Claude Code:
- First pass — Generic: Paste the function and ask "What edge cases am I not testing?" to get a broad view.
- Second pass — Systematic: Explicitly ask for the categories that were missing (empty, boundary, unicode, etc.).
- Third pass — Adversarial: "Try to break this function" for attack cases or malformed inputs.
- Filtering: Review the list and discard the irrelevant (impossible scenarios, zero impact).
- Implementation: Turn the approved edge cases into parametrized tests grouped by category.
You don't need to do all five passes for every function. For critical code (auth, payments, parsers), do all three. For simple utilities, one or two passes usually suffice.
An example of the output: the systematic prompt
For the parse_url function:
The prompt:
For parse_url(url: str) -> dict that returns protocol, domain, path, params,
generate edge cases in: empty, boundary, types, unicode, large inputs.
Include the input and the expected behavior.
A typical Claude Code output:
| Category | Input | Expected behavior |
|---|---|---|
| Empty | "" | A clear error or a dict with empty values |
| Empty | None | TypeError or explicit validation |
| Boundary | "a" | Protocol? Domain? |
| Boundary | "http://" | An empty domain |
| Types | 123 | TypeError |
| Unicode | "http://münchen.de/path" | Normalization/encoding |
| Unicode | "http://例え.jp/" | IDN handling |
| Large | a 1M-character string | Timeout, truncation, or a reasonable limit |
With this kind of prompt you get a matrix of cases you'd hardly generate manually.
A Practical Workflow: From the Function to the Edge Case Suite
Follow this flow when you work with Claude Code for edge case discovery:
Step 1: Provide complete context
Share the function's signature, its purpose and any known constraints:
I have this function that parses URLs:
[paste the code]
The contract is: it accepts a non-empty str, returns a dict with protocol, domain, path, params.
It must reject "" and None with a ValueError.
Step 2: Ask for edge cases by category
Use the systematic prompt with the framework of 10 categories:
Generate edge cases in: empty, boundary, types, unicode, large inputs, format.
For each one: an example input, the expected behavior, and why it matters.
Step 3: Filter out what doesn't apply
Review the generated list. Discard:
- Cases outside the contract (e.g. if the function doesn't accept
None, don't test "what it does with None" if it will always raise) - Extremely improbable cases with no impact
- Conceptual duplicates
Step 4: Ask for parametrized tests
Turn these edge cases into parametrized pytest tests.
Group them by category (empty_null, boundary, unicode, etc.).
Use pytest.param with descriptive ids.
Step 5: Run and adjust
Run pytest -v, review what passes and what fails. Adjust the expectations if the real behavior differs from what you assumed (maybe the function handles a case you didn't know about).
A Real Example: A URL Parser
The function
# url_parser.py
from urllib.parse import urlparse, parse_qs
from typing import Any
def parse_url(url: str) -> dict[str, Any]:
"""
Parse URL into components.
Returns: {"protocol": ..., "domain": ..., "path": ..., "params": ...}
"""
if not url or not isinstance(url, str):
raise ValueError("URL must be non-empty string")
parsed = urlparse(url)
params = {}
if parsed.query:
params = {k: v[0] if len(v) == 1 else v for k, v in parse_qs(parsed.query).items()}
return {
"protocol": parsed.scheme or None,
"domain": parsed.netloc or None,
"path": parsed.path or "/",
"params": params,
}
5 edge cases a human usually considers
"https://example.com"— A standard URL"https://example.com/path"— With a path"https://example.com?key=value"— With a query string""— Empty (maybe)"http://example.com"— Without SSL
15 edge cases Claude Code can generate
""— an empty stringNone— not a string" "— only spaces"example.com"— no protocol"://example.com"— an empty protocol"http://"— an empty domain"http://example.com/"— an empty path (root)"http://example.com//double/slash"— a double slash in the path"http://example.com/path?="— a query with=and no key"http://münchen.de"— a domain with Unicode characters"http://例え.jp/path"— an IDN domain"http://example.com/path?key="— an empty value in a param"http://example.com?key=1&key=2"— the same key repeated"http://" + "a" * 10_000_000— an extremely long URL"http://example.com/%00/path"— a null byte in the path
The gap
A human usually covers 1–5. Claude Code systematically adds 6–15: URLs without a protocol, double slashes, Unicode, duplicate IDs in the query, extreme sizes, null bytes. Those are the ones that usually cause bugs in production.
Evaluating AI-Generated Edge Cases
Not every edge case deserves a test. Ask yourself:
Is it a realistic scenario?
"http://example.com"→ yes"http://" + "x" * 10_000_000→ unlikely in normal use, but it can be an attack
Would a bug here have real impact?
- Data corruption → yes
- A security error → yes
- An ugly error message in an extremely rare case → maybe not
Criteria for including it
- ✅ Security: injection, overflow, control characters
- ✅ Data corruption: inputs that could damage the state
- ✅ User-visible errors: confusing messages or obvious crashes
Criteria for skipping it
- ❌ Scenarios that require very specific hardware or configuration
- ❌ Purely theoretical cases with no practical application
- ⚠️ Extremely improbable cases with minimal impact
Building an Edge Case Suite
Using parametrize to group by category
# test_url_parser.py
import pytest
from url_parser import parse_url
# Edge cases: empty/null
@pytest.mark.parametrize("url", ["", " ", None], ids=["empty", "whitespace", "none"])
def test_parse_url_rejects_empty_or_invalid(url):
if url is None:
with pytest.raises((TypeError, ValueError)):
parse_url(url)
else:
with pytest.raises(ValueError, match="non-empty"):
parse_url(url)
# Edge cases: boundary / format
@pytest.mark.parametrize("url,expected", [
("http://example.com", {"protocol": "http", "domain": "example.com", "path": "/", "params": {}}),
("http://example.com/", {"protocol": "http", "domain": "example.com", "path": "/", "params": {}}),
# "example.com" without a protocol: urlparse puts the domain in path, netloc is empty
("http://example.com/path", {"protocol": "http", "domain": "example.com", "path": "/path", "params": {}}),
], ids=["standard", "trailing_slash", "with_path"])
def test_parse_url_boundary_formats(url, expected):
result = parse_url(url)
assert result["protocol"] == expected["protocol"]
assert result["domain"] == expected["domain"]
assert result["path"] == expected["path"]
assert result["params"] == expected["params"]
# Edge cases: unicode (if your parser supports them)
@pytest.mark.parametrize("url", [
"http://münchen.de",
"http://例え.jp/",
], ids=["german_umlaut", "japanese_idn"])
def test_parse_url_unicode(url):
result = parse_url(url)
assert result["domain"] is not None
Documenting why it matters
@pytest.mark.parametrize("url,expected_path", [
# Double slashes: some systems treat //path as absolute
("http://example.com//foo", "//foo"),
# A query with a key and no value: APIs sometimes send key=
("http://example.com?a=", {"a": ""}),
], ids=["double_slash_path", "empty_param_value"])
def test_parse_url_format_edge_cases(url, expected_path):
result = parse_url(url)
# Document it in a comment: "A double slash can confuse routers"
assert "path" in result or "params" in result
Practice: A Complete Suite for a Validator
The function to test:
# validators.py
def is_valid_username(username: str, min_len: int = 3, max_len: int = 20) -> bool:
"""
Validate username: alphanumeric + underscore, length between min_len and max_len.
"""
if not isinstance(username, str):
return False
if not username or not username.strip():
return False
cleaned = username.strip()
if not (min_len <= len(cleaned) <= max_len):
return False
return all(c.isalnum() or c == "_" for c in cleaned)
An edge case suite with parametrize:
# test_validators.py
import pytest
from validators import is_valid_username
@pytest.mark.parametrize("username,expected", [
("alice", True),
("bob", True),
("user_123", True),
("a" * 20, True),
("ab", False), # too short
("a" * 21, False), # too long
("", False),
(" ", False),
(" alice ", True), # strip
("alice!", False), # special char
("alice bob", False), # space
("Alice", True), # uppercase ok
("123", True), # numeric only
("_alone", True), # leading underscore
], ids=[
"valid_medium",
"valid_short",
"valid_with_underscore",
"valid_max_length",
"too_short",
"too_long",
"empty",
"whitespace_only",
"with_spaces",
"special_char",
"space_in_middle",
"uppercase",
"numeric_only",
"leading_underscore",
])
def test_is_valid_username(username, expected):
assert is_valid_username(username) == expected
@pytest.mark.parametrize("invalid_input", [None, 123, [], {}], ids=["none", "int", "list", "dict"])
def test_is_valid_username_rejects_non_string(invalid_input):
assert is_valid_username(invalid_input) is False
Run it with pytest test_validators.py -v to see each case separately.
Exercises
Exercise 1: Categorize edge cases
You have the function def count_words(text: str) -> int. Assign each case to a category from the framework of 10:
"""a" * 10_000_000"hello""hello\n\tworld"None"café"(with an accent)" spaced "
See solution
| Input | Category |
|---|---|
"" | Empty/Null |
"a" * 10_000_000 | Very large inputs |
"hello" | Happy path (not an edge case) |
"hello\n\tworld" | Format edge cases (whitespace) |
None | Empty/Null / Type mismatches |
"café" | Unicode/Special chars |
" spaced " | Format edge cases (trailing/leading spaces) |
Exercise 2: A prompt for edge cases
Write a prompt that asks Claude Code for edge cases for def safe_divide(a: float, b: float) -> float, which returns a/b or 0 if b == 0.
See solution
For safe_divide(a, b), which returns a/b or 0 if b==0:
1. Generate edge cases in the categories: empty/null, boundary, types.
2. Include: division by zero, a zero numerator, negatives, large floats, wrong types.
3. For each case indicate the input (a, b) and the expected result.
A shorter alternative:
What edge cases should I test in safe_divide(a, b)?
Cover: b=0, a=0, negatives, wrong types, extreme floats.
Exercise 3: Parametrized tests for edge cases
Implement parametrized tests for safe_divide covering: (10,2)->5, (0,5)->0, (5,0)->0, (-10,2)->-5, (10,-2)->-5, and the rejection of None.
See solution
# math_utils.py
def safe_divide(a: float, b: float) -> float:
if b == 0:
return 0.0
return a / b
# test_math_utils.py
import pytest
from math_utils import safe_divide
@pytest.mark.parametrize("a,b,expected", [
(10, 2, 5.0),
(0, 5, 0.0),
(5, 0, 0.0),
(-10, 2, -5.0),
(10, -2, -5.0),
], ids=["normal", "zero_numerator", "zero_denominator", "negative_a", "negative_b"])
def test_safe_divide(a, b, expected):
assert safe_divide(a, b) == expected
def test_safe_divide_rejects_none():
with pytest.raises(TypeError):
safe_divide(None, 5)
If safe_divide doesn't validate types and lets it fail with a TypeError, the test can use pytest.raises(TypeError) as above.
Exercise 4: The human vs AI gap
For def get_extension(filename: str) -> str, which returns the extension (e.g. "file.txt" → "txt"), list 3 edge cases a human usually considers and 5 that Claude Code usually adds.
See solution
Humans usually think of:
"file.txt"→"txt""archive.tar.gz"→"gz"or"tar.gz"depending on the design"no_extension"→""or the complete name
Claude Code usually adds:
4. "" → what should it return?
5. ".hidden" → an empty extension or a hidden name
6. "file." → an empty extension
7. "file.Ñ.txt" → Unicode in the extension
8. "C:\\path\\file.txt" or "/path/.hidden/file.txt" → paths with separators
9. "file" + "\0" + ".txt" → a null byte in the name
Exercise 5: Evaluate and filter
Claude Code suggests these edge cases for an API that processes page and limit:
page=-1page=0page=2**64page="one"limit=0limit=-1page=1, limit=10(the happy path)page=1, limit=10run 1000 times in 1ms (concurrency)
Say which ones you'd include and which you'd discard, with a reason.
See solution
| Case | Include | Reason |
|---|---|---|
page=-1 | Yes | Invalid pagination, very realistic |
page=0 | Yes | It may or may not be valid depending on the design |
page=2**64 | Optional | Overflow, more relevant if you use 32-bit ints |
page="one" | Yes | A typical type error (query params come as strings) |
limit=0 | Yes | It can generate errors or empty results |
limit=-1 | Yes | Invalid, could be used to extract a lot of data |
| The happy path | Yes | Always include it |
| Concurrency 1000x | No (or separately) | That's a load test, not a unit test |
Exercise 6: An edge case suite with parametrize
The function def parse_list_from_string(s: str) -> list[str] expects strings like "a,b,c" and returns ["a","b","c"]. Write a parametrized suite that covers: empty, a single element, spaces, duplicates, a very long string, a trailing comma, a leading comma.
See solution
# parsers.py
def parse_list_from_string(s: str) -> list[str]:
if not s or not isinstance(s, str):
return []
return [part.strip() for part in s.split(",") if part.strip()]
# test_parsers.py
import pytest
from parsers import parse_list_from_string
@pytest.mark.parametrize("s,expected", [
("", []),
("a", ["a"]),
("a,b,c", ["a", "b", "c"]),
(" a , b , c ", ["a", "b", "c"]),
("a,a,a", ["a", "a", "a"]),
("a,,b", ["a", "b"]),
("a,", ["a"]),
(",a", ["a"]),
], ids=[
"empty",
"single",
"multiple",
"with_spaces",
"duplicates",
"empty_parts",
"trailing_comma",
"leading_comma",
])
def test_parse_list_from_string(s, expected):
assert parse_list_from_string(s) == expected
def test_parse_list_from_string_rejects_none():
with pytest.raises((TypeError, AttributeError)):
parse_list_from_string(None)
If parse_list_from_string accepts None and returns [], the test for None would be assert parse_list_from_string(None) == [].
Troubleshooting
1. Claude Code generates too many edge cases
The problem: The response includes 50+ cases and you don't know where to start.
The solution: Ask it to prioritize:
Out of the edge cases you generated, prioritize the 10 most important.
Criteria: security impact, data corruption, user-visible errors.
Or restrict the categories:
Generate only edge cases in the categories: empty, boundary, types.
Maximum 3 per category.
2. The parametrized tests fail because of types
The problem: pytest.raises in one case and assert in another, mixed in the same parametrize.
The solution: Separate the tests: one for the happy path and normal cases, another for exceptions:
@pytest.mark.parametrize("input_val,expected", [(1, 1), (2, 2)])
def test_valid(input_val, expected):
assert func(input_val) == expected
@pytest.mark.parametrize("invalid", [None, ""])
def test_invalid(invalid):
with pytest.raises(ValueError):
func(invalid)
3. Edge cases that depend on the implementation
The problem: Claude Code proposes cases that would only make sense with a different implementation.
The solution: Clarify the contract:
Generate edge cases for this function.
The contract is: it accepts a non-empty str, returns a dict. It doesn't accept None.
Focus on edge cases within that contract.
4. Duplication among AI-generated tests
The problem: Several tests cover the same edge case with different wording.
The solution: Ask for consolidation:
Review these tests. Consolidate duplicate cases using @pytest.mark.parametrize.
Keep one case per unique behavior.
5. Unreal or impossible edge cases
The problem: Cases like "the disk is full" or "the network is down" for a pure function.
The solution: Narrow the domain:
This function is pure: it receives a string, returns a string.
It doesn't consider I/O, the network or the filesystem.
Generate edge cases only about the input string.
Project Connection
In this module's project ("A suite with 90%+ coverage") you'll work with existing code that has coverage gaps. Edge case discovery with Claude Code helps you:
- ✅ Close coverage gaps in rarely exercised branches and conditions
- ✅ Find unexpected behaviors before they reach production
- ✅ Document the why of each edge case (security, data, UX)
A suggested workflow:
- Measure coverage (
pytest --cov) - Identify functions with low coverage or uncovered branches
- Ask Claude Code for edge cases for those functions with systematic prompts
- Filter the relevant cases
- Implement parametrized tests grouped by category
- Measure coverage again
The next capsule (05) complements this with boundary value analysis and property-based testing with hypothesis.
Next capsule: Capsule 05 — Boundary value analysis and property-based testing with hypothesis. You'll learn to define equivalence intervals, limit values just inside and outside the ranges, and how hypothesis can generate thousands of inputs automatically to discover cases neither you nor Claude Code would have thought of.
Summary
- ✅ Humans tend to focus on the happy path and familiar cases; Claude Code can explore categories of edge cases more systematically
- ✅ Use the framework of 10 categories: Empty/Null, Boundary, Types, Unicode, Large/Small inputs, Duplicates, Ordering, Concurrency, Format
- ✅ Useful prompts: generic, systematic, adversarial, security
- ✅ Not every edge case deserves a test: prioritize impact on security, data and the user
- ✅ Group tests with
@pytest.mark.parametrizeby category and document why they matter - ✅ Use edge case discovery to close coverage gaps in the module's project
Additional Resources
- OWASP Testing Guide — Edge cases and security testing
- Hypothesis: What is property-based testing? — Automatic input generation
- pytest: Parametrize — Test parametrization
- Boundary Value Analysis (Wikipedia) — Boundary value analysis
- Python URL parsing (urllib.parse) — A reference for the URL example
- Fuzzing with AFL — An automated adversarial approach, for inspiration
Next capsule: Capsule 05 covers boundary value analysis and property-based testing with hypothesis — the complement that discovers problematic inputs automatically where the prompts cover the known.
Module 5, Capsule 04 — Testing with Claude Code Guide