top of page

Test Error Paths and Edge Cases — Hands-On Software Testing, Part 8

S. West
Jul 8
4 min read

Updated: Oct 4

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.



To aim your own suite at the places where failure costs something, the Risk-Based Test Strategy Worksheet maps risk, checks that tests assert behaviour, lists the edge cases and finds the boundaries that need contract tests.


Continue the Hands-On Software Testing path



Part of the Hands-On Software Testing learning path.

bottom of page