Python API Development with FastAPI

Testing

Testing Exceptions

In this article, we enhance our BankAccount class by introducing exception handling for withdrawal operations. This update ensures that users cannot withdraw more money than is available in their account. The guide covers the original implementation, the issue with insufficient balance checks, testing adjustments, and the introduction of a custom exception for better error handling.

Original BankAccount Implementation

Initially, our BankAccount class was implemented as follows:

class BankAccount():
    def __init__(self, starting_balance=0):
        self.balance = starting_balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount

    def collect_interest(self):
        self.balance *= 1.1

When running tests, all tests passed:

PASSED
tests/test_calculations.py::test_bank_transaction[50-10-40] creating empty bank account
PASSED
tests/test_calculations.py::test_bank_transaction[1200-200-1000] creating empty bank account
================================== 14 passed in 0.09s ==================================

Problem: Insufficient Funds Check

Upon inspection, the withdraw method does not verify if the account has sufficient funds. For example, if the account has $100 and a withdrawal of $500 is attempted, the operation should be blocked. To address this, we add a check in the withdraw method that raises an exception when necessary.

Updated BankAccount Implementation with Basic Exception

The revised BankAccount class adds a condition to the withdraw method to raise an exception if the withdrawal amount exceeds the account balance:

class BankAccount():
    def __init__(self, starting_balance=0):
        self.balance = starting_balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise Exception("Insufficient funds in account")
        self.balance -= amount

    def collect_interest(self):
        self.balance *= 1.1

Re-running the tests produces the following output:

PASSED
tests/test_calculations.py::test_bank_transaction[50-10-40] creating empty bank account
PASSED
tests/test_calculations.py::test_bank_transaction[1200-200-1000] creating empty bank account
PASSED
================= 14 passed in 0.09s =================

Adjusting Tests for Exception Handling

The original parameterized test included a case where more money was withdrawn than deposited:

import pytest

@pytest.mark.parametrize("deposited, withdrew, expected", [
    (200, 100, 100),
    (50, 10, 40),
    (1200, 200, 1000),
    (10, 200, 1000),
])
def test_bank_transaction(zero_bank_account, deposited, withdrew, expected):
    zero_bank_account.deposit(deposited)
    zero_bank_account.withdraw(withdrew)
    assert zero_bank_account.balance == expected

The test output indicated a failure for the case where 10 is deposited and 200 is withdrawn:

self = <app.calculations.BankAccount object at 0x0000018C971BC940>, amount = 50

def withdraw(self, amount):
    if amount > self.balance:
        raise Exception("Insufficient funds in account")
E       Exception: Insufficient funds in account

app/calculations.py:27: Exception
================================= short test summary info =================================
FAILED tests/test_calculations.py::test_bank_transaction[10-50-40] - Exception: Insufficient funds in account

Since an exception is expected for insufficient funds, it's better to remove this failing test case from the parameterized group and create a dedicated test.

Creating a Dedicated Test for Insufficient Funds

We use Pytest’s raises context manager for this purpose. For an account with an initial balance of zero (using the zero_bank_account fixture), attempting to withdraw money should trigger an exception:

def test_bank_transaction(zero_bank_account, deposited, withdrew, expected):
    zero_bank_account.deposit(deposited)
    zero_bank_account.withdraw(withdrew)
    assert zero_bank_account.balance == expected

def test_insufficient_funds(bank_account):
    with pytest.raises(Exception):
        bank_account.withdraw(200)

Here, the bank_account fixture initializes an account with a balance (for instance, 50) to ensure that withdrawing $200 raises the expected exception. Running the tests now produces:

tests/test_calculations.py::test_bank_transaction[50-10-40] creating empty bank account
tests/test_calculations.py::test_bank_transaction[1200-200-1000] creating empty bank account
tests/test_calculations.py::test_insufficient_funds PASSED
15 passed in 0.09s

Introducing a Custom Exception: InsufficientFunds

For better clarity and maintainability in production code, we define a custom exception called InsufficientFunds that inherits from Python’s built-in Exception class. This approach allows our tests to verify that the correct exception is raised.

Defining the Custom Exception

class InsufficientFunds(Exception):
    pass

Updated BankAccount Class with Custom Exception

The BankAccount class is updated to raise InsufficientFunds when an over-withdrawal is attempted:

class BankAccount():
    def __init__(self, starting_balance=0):
        self.balance = starting_balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFunds("Insufficient funds in account")
        self.balance -= amount

    def collect_interest(self):
        self.balance *= 1.1

Updating the Test for Insufficient Funds

We also update the insufficient funds test to assert that an InsufficientFunds exception is raised:

def test_insufficient_funds(bank_account):
    with pytest.raises(InsufficientFunds):
        bank_account.withdraw(200)

Running the tests now confirms that the proper exception is raised:

tests/test_calculations.py::test_bank_transaction[200-100-100] creating empty bank account PASSED
tests/test_calculations.py::test_bank_transaction[50-10-40] creating empty bank account PASSED
tests/test_calculations.py::test_bank_transaction[1200-200-1000] creating empty bank account PASSED
tests/test_calculations.py::test_insufficient_funds PASSED

15 passed in 0.08s

Note

Specifying the exact exception type ensures that our code not only raises an error but also raises the correct, expected type. This improves test precision and code reliability.

Demonstrating the Importance of the Correct Exception Type

To highlight the importance of using a custom exception, consider a scenario where ZeroDivisionError is raised instead of InsufficientFunds. For example, changing the withdraw method as shown below would trigger a test failure:

def withdraw(self, amount):
    if amount > self.balance:
        # raise InsufficientFunds("Insufficient funds in account")
        raise ZeroDivisionError()
    self.balance -= amount

Test output in this case would be:

app/calculations.py:32: ZeroDivisionError
FAILED tests/test_calculations.py::test_insufficient_funds - ZeroDivisionError

This failure confirms that our tests correctly check for a specific exception, and any deviation from the expected behavior will be flagged immediately.

Conclusion

By defining and raising a custom exception for insufficient funds, our BankAccount class now operates in a robust and predictable manner. The updated tests validate this behavior precisely, ensuring that the internal logic of our application remains stable and error-free.

Happy testing!

Watch Video

Watch video content

Previous
Fixtures