top of page

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

Part of the Hands-On Software Testing learning path.

bottom of page