🎓 A1Learner
🔍

Python Testing Exceptions & Edge Cases

Testing exceptions checks that code raises the right error with the right message. Errors are behaviour too.

Check the message, not just the type

The as ctx part keeps the exception, so you can read its message.

import unittest

def divide(a, b):
    return a / b

checker = unittest.TestCase()
with checker.assertRaises(ZeroDivisionError) as ctx:
    divide(1, 0)
print(str(ctx.exception))

Test both sides of a boundary

The rule turns at 18. So test 17, 18, and 19.

def is_adult(age):
    return age >= 18

print(is_adult(17), is_adult(18), is_adult(19))

File tests with tempfile

The folder exists inside the block. It is gone after the block.

import os
import tempfile

with tempfile.TemporaryDirectory() as folder:
    path = os.path.join(folder, "note.txt")
    with open(path, "w") as f:
        f.write("hi")
    print("Inside the block, the file exists:", os.path.exists(path))
print("After the block, the file exists:", os.path.exists(path))

Catch a whole family

withdraw is defined in the closing example. Asking for the base class accepts any family member.

with self.assertRaises(BankError):
    withdraw(10, 20)

Example

import unittest
import tempfile
import os

class BankError(Exception):
    pass

class InsufficientFundsError(BankError):
    pass

class InvalidAmountError(BankError):
    pass

def withdraw(balance, amount):
    if amount <= 0:
        raise InvalidAmountError("Amount must be positive")
    if amount > balance:
        raise InsufficientFundsError("Not enough money")
    return balance - amount

def save_receipt(folder, text):
    path = os.path.join(folder, "receipt.txt")
    with open(path, "w") as f:
        f.write(text)
    return path

class TestWithdraw(unittest.TestCase):
    def test_normal_withdraw(self):
        self.assertEqual(withdraw(100, 30), 70)

    def test_too_much_raises(self):
        with self.assertRaises(InsufficientFundsError) as ctx:
            withdraw(50, 80)
        self.assertEqual(str(ctx.exception), "Not enough money")

    def test_zero_amount_raises(self):
        with self.assertRaises(InvalidAmountError):
            withdraw(50, 0)

    def test_exact_balance_works(self):
        self.assertEqual(withdraw(50, 50), 0)

    def test_receipt_uses_temp_folder(self):
        with tempfile.TemporaryDirectory() as folder:
            path = save_receipt(folder, "Paid")
            with open(path) as f:
                self.assertEqual(f.read(), "Paid")

if __name__ == "__main__":
    unittest.main()

Key points

  • Use assertRaises as a context manager to test the code in the with block.
  • Save the exception with as ctx and check str(ctx.exception) for the message.
  • Boundary value: a value right at the edge of a rule, like age 17 and age 18.
  • Test both sides of every boundary.
  • Use tempfile.TemporaryDirectory for file tests.
  • The folder removes itself afterwards.
  • A test for the base class of an exception family catches every member of the family.
  • Never print a temp path in a test.
  • Testing exceptions means checking both the error type and its message.

In this example, one file holds a small exception family and its tests. BankError is the base class, in the style of the exceptions section. Its two children name the two ways withdraw can refuse. One test checks the message text through ctx.exception. One test checks the exact-balance boundary, which must succeed. The receipt test writes inside a TemporaryDirectory, so no file is left behind. Run the file with python3 -m unittest. The Ran line also reports a time in seconds, which changes on every run. OK means all five tests passed.

Note

The name is random, so the output would change on every run.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Python Playground

Exercise

A rule says a ticket is free for children under 12. Which pair of ages best tests the boundary?