Testing that your code works with good input is the starting point. Testing that your code fails correctly with bad input is what makes it reliable. A function that returns the right answer for valid data but crashes on an empty list is not finished.
A function that silently returns wrong results for negative numbers is worse than one that raises a clear error.
This article covers how to test that your Python code raises the right exceptions and handles edge cases correctly. It builds on the assertion methods from Python unittest explained and applies them to the inputs that most developers forget to test.
Testing that a function raises an exception
The assertRaises method verifies that a specific exception is raised when your code encounters invalid input. Use it as a context manager with the with statement. The test passes if the expected exception is raised inside the block and fails if no exception or a different exception occurs.
import unittest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
class TestDivide(unittest.TestCase):
def test_divide_by_zero_raises_error(self):
with self.assertRaises(ValueError):
divide(10, 0)The first test confirms that dividing by zero raises a ValueError, proving the function refuses the invalid input instead of crashing with a raw ZeroDivisionError or returning a nonsense value.
def test_divide_works_normally(self):
result = divide(10, 2)
self.assertEqual(result, 5)The second test confirms that normal division still works. Both tests together tell you the function handles both paths correctly.
You can also check the exception message to make sure the right error is raised for the right reason. The context manager captures the exception, and you can inspect it after the block:
def test_divide_by_zero_message(self):
with self.assertRaises(ValueError) as context:
divide(10, 0)
self.assertIn("Cannot divide", str(context.exception))This pattern is useful when your function raises the same exception type for different reasons. It lets you distinguish which case triggered the error by checking the message.
Testing boundary values
A boundary value is the exact point where behavior should change. If a function accepts ages 18 and above, the boundary is 18.
Test 17 to confirm it is rejected. Test 18 to confirm it is accepted. Test 19 to confirm the acceptance does not accidentally break one step above the boundary.
def can_vote(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age >= 18
class TestCanVote(unittest.TestCase):
def test_just_below_boundary(self):
self.assertFalse(can_vote(17))
def test_exactly_at_boundary(self):
self.assertTrue(can_vote(18))
def test_just_above_boundary(self):
self.assertTrue(can_vote(19))Testing the exact boundary and one value on each side catches off-by-one errors, which are among the most common bugs in conditional logic. A function that uses > instead of >= will fail at the boundary. A function that checks the wrong variable will fail on either side.
Testing empty and missing inputs
Empty collections, empty strings, and None are the edge cases that cause the most runtime crashes. A function that processes a list should handle an empty list.
A function that reads a string should handle an empty string. Every function that accepts an argument should decide what to do with None.
def average(numbers):
if not numbers:
raise ValueError("Cannot average an empty list")
return sum(numbers) / len(numbers)
class TestAverage(unittest.TestCase):
def test_normal_list(self):
self.assertEqual(average([2, 4, 6]), 4)
def test_empty_list_raises_error(self):
with self.assertRaises(ValueError):
average([])The empty list test confirms the function rejects invalid input rather than raising a confusing ZeroDivisionError from dividing by a length of zero. The normal list test covers the happy path.
def test_single_element(self):
self.assertEqual(average([7]), 7)The single-element test confirms the function works at the smallest valid input size. Both are edge cases that a test written only for the happy path would miss.
Testing wrong types
Python is dynamically typed, so a function can receive an integer where a string is expected or a list where a dictionary is needed. Your tests should verify that the function either handles the wrong type gracefully or raises a clear TypeError.
def repeat(text, count):
if not isinstance(count, int):
raise TypeError("Count must be an integer")
return text * count
class TestRepeat(unittest.TestCase):
def test_float_count_raises_error(self):
with self.assertRaises(TypeError):
repeat("hello", 2.5)
def test_string_count_raises_error(self):
with self.assertRaises(TypeError):
repeat("hello", "three")The isinstance check validates the type and raises a clear error. The tests confirm that both a float and a string trigger the TypeError. Without these tests, a caller passing the wrong type might receive a confusing error from deep inside the function or, worse, an incorrect result.
Using subTest for multiple edge cases
When you have many similar edge cases, writing a separate test method for each one creates repetition. The subTest context manager lets you loop over cases in one method while reporting each case independently.
class TestIsValidUsername(unittest.TestCase):
def test_invalid_usernames(self):
invalid_cases = [
("", "empty string"),
("ab", "too short"),
("user@name", "contains special character"),
]
for value, description in invalid_cases:
with self.subTest(case=description):
self.assertFalse(is_valid_username(value))Each failing case is reported with its description, so you know exactly which edge case broke without adding print statements or running tests one at a time. This pattern scales well when you discover new edge cases and need to add them to the list.
Building an edge case checklist
When you finish a function, run through this checklist before considering it done:
- Test with an empty input, the smallest valid input, and the largest reasonable input.
- Test with None if the parameter is optional.
- Test with the wrong type.
- Test just below and just above every numeric threshold.
- Test with duplicated values if the function processes collections.
Not every function needs every check. The checklist ensures you at least consider each category before moving on.
The next article covers mocking objects and external dependencies, which is how you test code that depends on databases, APIs, and other systems you cannot control during a unit test.
Rune AI
Key Insights
- Use assertRaises as a context manager to verify that a function raises the correct exception for invalid inputs.
- Test boundary values at the exact threshold where behavior changes, like the minimum length or the zero value.
- Test empty collections, None, and wrong types as separate categories of edge cases.
- Use subTest to run multiple edge case checks in a single test method while reporting each independently.
- A test that verifies an exception is raised is just as valuable as a test that verifies correct output.
Frequently Asked Questions
What is the difference between an edge case and a boundary value?
Should I test every possible invalid input?
How do I test that a function does NOT raise an exception?
Conclusion
Testing exceptions and edge cases turns a fragile test suite into a reliable one. The happy path shows that your code works under ideal conditions. The error path shows that your code fails safely when conditions are not ideal. Both matter equally for production code.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.