Assignment Overview
In this assignment, you will build a Banking System that demonstrates professional-level error handling, debugging, and testing practices. This project requires you to apply ALL concepts from Module 7: exception handling, custom exceptions, logging, unittest, pytest, and test-driven development.
Exception Handling (7.1)
Try/except/else/finally, multiple exceptions, raising, custom exceptions
Debugging & Logging (7.2)
Print debugging, pdb debugger, logging module with levels
Testing (7.3)
unittest, pytest, fixtures, mocking, TDD, coverage
The Scenario
SecureBank Python System
You have been hired as a Quality Assurance Engineer at SecureBank, a digital bank that needs a rock-solid transaction system. The CTO has given you this task:
"We need a banking system that never fails silently. Every error must be caught, logged, and handled gracefully. The system must have comprehensive test coverage - we can't afford bugs in production. Use TDD to build the core features, implement custom exceptions for banking errors, and set up proper logging for audit trails."
Your Task
Create a Python banking system with robust error handling, comprehensive logging, and thorough test coverage. Your code must demonstrate proficiency in all error handling and testing concepts taught in Module 7.
Requirements
Your project must implement ALL of the following requirements. Each requirement is mandatory and will be tested individually.
Custom Exception Hierarchy (7.1)
Create a hierarchy of custom banking exceptions:
BankingError- Base exception for all banking errorsInsufficientFundsError- When withdrawal exceeds balanceInvalidAmountError- When amount is negative or zeroAccountNotFoundError- When account doesn't existTransactionLimitError- When daily limit exceededAuthenticationError- When PIN/password is wrong
class BankingError(Exception):
"""Base exception for all banking-related errors."""
def __init__(self, message: str, error_code: str = None):
self.message = message
self.error_code = error_code
super().__init__(self.message)
def __str__(self):
if self.error_code:
return f"[{self.error_code}] {self.message}"
return self.message
class InsufficientFundsError(BankingError):
"""Raised when withdrawal amount exceeds available balance."""
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
message = f"Insufficient funds: balance ₹{balance:.2f}, requested ₹{amount:.2f}"
super().__init__(message, "ERR_INSUFFICIENT_FUNDS")
class InvalidAmountError(BankingError):
"""Raised when transaction amount is invalid (negative or zero)."""
def __init__(self, amount: float):
self.amount = amount
message = f"Invalid amount: ₹{amount:.2f}. Amount must be positive."
super().__init__(message, "ERR_INVALID_AMOUNT")
# Implement remaining custom exceptions...
Account Class with Exception Handling (7.1)
Create an Account class that uses try/except/else/finally:
- All methods must have proper exception handling
- Use
elseblock for success operations - Use
finallyfor cleanup/logging - Raise appropriate custom exceptions
- Never let exceptions propagate unhandled
class Account:
"""Bank account with robust error handling."""
DAILY_WITHDRAWAL_LIMIT = 50000.0
def __init__(self, account_number: str, holder_name: str,
pin: str, initial_balance: float = 0.0):
self._account_number = account_number
self._holder_name = holder_name
self._pin = pin
self._balance = initial_balance
self._daily_withdrawn = 0.0
self._transaction_history = []
def withdraw(self, amount: float, pin: str) -> float:
"""
Withdraw money from account.
Raises:
AuthenticationError: If PIN is incorrect
InvalidAmountError: If amount <= 0
InsufficientFundsError: If amount > balance
TransactionLimitError: If daily limit exceeded
"""
try:
# Validate PIN
if pin != self._pin:
raise AuthenticationError("Invalid PIN")
# Validate amount
if amount <= 0:
raise InvalidAmountError(amount)
# Check balance
if amount > self._balance:
raise InsufficientFundsError(self._balance, amount)
# Check daily limit
if self._daily_withdrawn + amount > self.DAILY_WITHDRAWAL_LIMIT:
raise TransactionLimitError(
self.DAILY_WITHDRAWAL_LIMIT,
self._daily_withdrawn
)
except BankingError:
# Re-raise banking errors after logging
raise
else:
# Success - perform withdrawal
self._balance -= amount
self._daily_withdrawn += amount
self._record_transaction("WITHDRAWAL", amount)
return self._balance
finally:
# Always log the attempt
logger.info(f"Withdrawal attempt on {self._account_number}")
Multiple Exception Handling (7.1)
Implement a transfer() method that handles multiple exceptions:
- Handle multiple exception types in one except block
- Use exception chaining with
raise ... from - Implement proper rollback on failure
- Log all exceptions with traceback
def transfer(self, to_account: 'Account', amount: float, pin: str) -> bool:
"""
Transfer money to another account.
Handles multiple exceptions and implements rollback.
"""
try:
# Attempt withdrawal from this account
self.withdraw(amount, pin)
try:
# Attempt deposit to target account
to_account.deposit(amount)
except (InvalidAmountError, AccountNotFoundError) as e:
# Rollback: restore balance to this account
self._balance += amount
self._daily_withdrawn -= amount
raise TransferError(
f"Transfer failed, funds restored: {e}"
) from e
except (InsufficientFundsError, TransactionLimitError,
AuthenticationError) as e:
logger.error(f"Transfer failed: {e}")
raise
except Exception as e:
# Catch any unexpected errors
logger.exception("Unexpected error during transfer")
raise BankingError(f"Transfer failed: {str(e)}") from e
else:
logger.info(f"Transfer successful: ₹{amount}")
return True
Logging Configuration (7.2)
Set up comprehensive logging with multiple handlers:
- Configure root logger with appropriate level
- File handler for all logs (DEBUG level)
- Console handler for warnings and above
- Separate file for error logs only
- Use proper log formatting with timestamps
import logging
import logging.handlers
from datetime import datetime
def setup_logging():
"""Configure logging for the banking system."""
# Create logger
logger = logging.getLogger('banking')
logger.setLevel(logging.DEBUG)
# Create formatters
detailed_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - '
'%(filename)s:%(lineno)d - %(message)s'
)
simple_formatter = logging.Formatter(
'%(levelname)s - %(message)s'
)
# File handler - all logs
file_handler = logging.FileHandler('banking.log')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(detailed_formatter)
# Error file handler - errors only
error_handler = logging.FileHandler('banking_errors.log')
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(detailed_formatter)
# Console handler - warnings and above
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)
console_handler.setFormatter(simple_formatter)
# Add handlers to logger
logger.addHandler(file_handler)
logger.addHandler(error_handler)
logger.addHandler(console_handler)
return logger
logger = setup_logging()
Logging Throughout the Application (7.2)
Add logging statements at appropriate levels:
DEBUG: Detailed diagnostic informationINFO: Successful operations, transactionsWARNING: Low balance alerts, unusual activityERROR: Failed operations, caught exceptionsCRITICAL: Security breaches, system failures
class Bank:
"""Bank system with comprehensive logging."""
def __init__(self, name: str):
self._name = name
self._accounts = {}
logger.info(f"Bank '{name}' initialized")
def create_account(self, holder_name: str, initial_deposit: float,
pin: str) -> Account:
"""Create a new bank account."""
logger.debug(f"Creating account for {holder_name}")
try:
if initial_deposit < 1000:
logger.warning(
f"Low initial deposit: ₹{initial_deposit}"
)
account_number = self._generate_account_number()
account = Account(account_number, holder_name, pin,
initial_deposit)
self._accounts[account_number] = account
logger.info(
f"Account created: {account_number} for {holder_name}"
)
return account
except Exception as e:
logger.error(f"Failed to create account: {e}")
raise
def authenticate(self, account_number: str, pin: str) -> bool:
"""Authenticate user with account number and PIN."""
logger.debug(f"Authentication attempt for {account_number}")
if account_number not in self._accounts:
logger.warning(f"Auth failed: account {account_number} not found")
return False
account = self._accounts[account_number]
if account._pin != pin:
logger.critical(
f"SECURITY: Failed PIN attempt for {account_number}"
)
return False
logger.info(f"Authentication successful: {account_number}")
return True
Unit Tests with unittest (7.3)
Write comprehensive tests using the unittest framework:
- Test class for each main class (Account, Bank)
- setUp() and tearDown() methods for test fixtures
- Test both success and failure cases
- Test exception raising with assertRaises
- Use subTest for parameterized tests
import unittest
from banking import Account, Bank
from exceptions import (
InsufficientFundsError, InvalidAmountError,
AuthenticationError, TransactionLimitError
)
class TestAccount(unittest.TestCase):
"""Unit tests for Account class using unittest."""
def setUp(self):
"""Set up test fixtures."""
self.account = Account(
account_number="ACC001",
holder_name="John Doe",
pin="1234",
initial_balance=10000.0
)
def tearDown(self):
"""Clean up after tests."""
self.account = None
def test_deposit_valid_amount(self):
"""Test depositing a valid amount."""
new_balance = self.account.deposit(5000.0)
self.assertEqual(new_balance, 15000.0)
def test_deposit_invalid_amount_raises_error(self):
"""Test that depositing invalid amount raises exception."""
with self.assertRaises(InvalidAmountError) as context:
self.account.deposit(-100.0)
self.assertIn("Invalid amount", str(context.exception))
def test_withdraw_with_wrong_pin(self):
"""Test withdrawal with incorrect PIN."""
with self.assertRaises(AuthenticationError):
self.account.withdraw(1000.0, "wrong")
def test_withdraw_insufficient_funds(self):
"""Test withdrawal exceeding balance."""
with self.assertRaises(InsufficientFundsError) as context:
self.account.withdraw(50000.0, "1234")
self.assertEqual(context.exception.balance, 10000.0)
self.assertEqual(context.exception.amount, 50000.0)
def test_multiple_amounts(self):
"""Test deposits with multiple amounts using subTest."""
amounts = [100, 500, 1000, 5000]
for amount in amounts:
with self.subTest(amount=amount):
initial = self.account.balance
self.account.deposit(amount)
self.assertEqual(
self.account.balance,
initial + amount
)
if __name__ == '__main__':
unittest.main()
Tests with pytest (7.3)
Write tests using pytest with its features:
- Use
@pytest.fixturefor test setup - Use
@pytest.mark.parametrizefor multiple test cases - Test exceptions with
pytest.raises - Use markers for test categorization
- Write conftest.py for shared fixtures
import pytest
from banking import Account, Bank
from exceptions import *
# conftest.py - shared fixtures
@pytest.fixture
def sample_account():
"""Fixture providing a sample account."""
return Account(
account_number="ACC001",
holder_name="John Doe",
pin="1234",
initial_balance=10000.0
)
@pytest.fixture
def bank_with_accounts():
"""Fixture providing a bank with multiple accounts."""
bank = Bank("Test Bank")
bank.create_account("Alice", 5000.0, "1111")
bank.create_account("Bob", 10000.0, "2222")
return bank
# test_account_pytest.py
class TestAccountPytest:
"""Pytest tests for Account class."""
def test_deposit_increases_balance(self, sample_account):
"""Test that deposit increases balance correctly."""
sample_account.deposit(5000.0)
assert sample_account.balance == 15000.0
@pytest.mark.parametrize("amount,expected", [
(100, 10100),
(500, 10500),
(1000, 11000),
(0.01, 10000.01),
])
def test_deposit_various_amounts(self, sample_account, amount, expected):
"""Test deposits with various amounts."""
sample_account.deposit(amount)
assert sample_account.balance == pytest.approx(expected, rel=1e-2)
@pytest.mark.parametrize("invalid_amount", [-100, -1, 0, -0.01])
def test_deposit_invalid_amounts(self, sample_account, invalid_amount):
"""Test that invalid amounts raise InvalidAmountError."""
with pytest.raises(InvalidAmountError):
sample_account.deposit(invalid_amount)
@pytest.mark.slow
def test_daily_limit_enforcement(self, sample_account):
"""Test that daily withdrawal limit is enforced."""
# Withdraw up to limit
sample_account.withdraw(50000.0, "1234")
# Next withdrawal should fail
with pytest.raises(TransactionLimitError):
sample_account.withdraw(1.0, "1234")
Mocking and Patching (7.3)
Use mocking to isolate units under test:
- Mock external dependencies (logging, file I/O)
- Use
unittest.mock.patchdecorator - Mock datetime for time-dependent tests
- Verify mock calls with assert_called_with
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime
class TestBankMocking:
"""Tests using mocking and patching."""
@patch('banking.logger')
def test_logging_on_deposit(self, mock_logger, sample_account):
"""Test that deposit logs correctly."""
sample_account.deposit(1000.0)
mock_logger.info.assert_called()
@patch('banking.datetime')
def test_transaction_timestamp(self, mock_datetime, sample_account):
"""Test transaction records correct timestamp."""
mock_datetime.now.return_value = datetime(2026, 1, 22, 10, 30)
sample_account.deposit(1000.0)
last_txn = sample_account.transaction_history[-1]
assert last_txn['timestamp'] == datetime(2026, 1, 22, 10, 30)
def test_transfer_with_mock_account(self, sample_account):
"""Test transfer using a mock target account."""
mock_target = Mock(spec=Account)
mock_target.deposit.return_value = 5000.0
sample_account.transfer(mock_target, 1000.0, "1234")
mock_target.deposit.assert_called_once_with(1000.0)
@patch('banking.Bank._generate_account_number')
def test_account_number_generation(self, mock_gen, bank_with_accounts):
"""Test that account number generator is called."""
mock_gen.return_value = "ACC999"
account = bank_with_accounts.create_account("Test", 1000, "0000")
assert account.account_number == "ACC999"
mock_gen.assert_called_once()
Test-Driven Development (TDD) (7.3)
Implement a new feature using TDD methodology:
- Feature: Interest calculation for savings accounts
- Write failing tests FIRST
- Write minimum code to pass tests
- Refactor while keeping tests green
- Document the TDD process in README
# Step 1: Write failing tests first (RED)
class TestInterestCalculation:
"""TDD tests for interest calculation feature."""
def test_calculate_monthly_interest(self):
"""Test monthly interest calculation."""
account = SavingsAccount("SAV001", "Jane", "1234", 10000.0)
# 5% annual rate = 0.417% monthly
interest = account.calculate_monthly_interest()
assert interest == pytest.approx(41.67, rel=0.01)
def test_apply_interest_increases_balance(self):
"""Test that applying interest increases balance."""
account = SavingsAccount("SAV001", "Jane", "1234", 10000.0)
account.apply_monthly_interest()
assert account.balance == pytest.approx(10041.67, rel=0.01)
def test_interest_rate_cannot_be_negative(self):
"""Test that negative interest rate raises error."""
with pytest.raises(InvalidAmountError):
SavingsAccount("SAV001", "Jane", "1234", 10000.0,
interest_rate=-0.05)
# Step 2: Write minimum code to pass (GREEN)
class SavingsAccount(Account):
"""Savings account with interest calculation."""
DEFAULT_INTEREST_RATE = 0.05 # 5% annual
def __init__(self, account_number, holder_name, pin,
initial_balance=0.0, interest_rate=None):
super().__init__(account_number, holder_name, pin, initial_balance)
if interest_rate is not None and interest_rate < 0:
raise InvalidAmountError(interest_rate)
self._interest_rate = interest_rate or self.DEFAULT_INTEREST_RATE
def calculate_monthly_interest(self) -> float:
"""Calculate monthly interest amount."""
monthly_rate = self._interest_rate / 12
return round(self._balance * monthly_rate, 2)
def apply_monthly_interest(self) -> float:
"""Apply monthly interest to balance."""
interest = self.calculate_monthly_interest()
self._balance += interest
return self._balance
# Step 3: Refactor (keeping tests green)
Test Coverage Report (7.3)
Generate and maintain test coverage:
- Use
pytest-covfor coverage reports - Achieve minimum 90% coverage
- Include coverage badge in README
- Generate HTML coverage report
- Identify and test uncovered branches
# Run tests with coverage
pytest --cov=banking --cov=exceptions --cov-report=html --cov-report=term
# Expected output:
# Name Stmts Miss Cover
# ----------------------------------------
# banking.py 150 12 92%
# exceptions.py 45 2 96%
# ----------------------------------------
# TOTAL 195 14 93%
# Coverage report saved to htmlcov/
# pytest.ini configuration
[pytest]
addopts = --cov=banking --cov=exceptions --cov-fail-under=90
testpaths = tests
markers =
slow: marks tests as slow
integration: marks integration tests
Integration Tests (7.3)
Write integration tests for complete workflows:
- Test complete user journey (create account → deposit → withdraw → transfer)
- Test error recovery scenarios
- Test logging output
- Use pytest markers to separate from unit tests
@pytest.mark.integration
class TestBankingIntegration:
"""Integration tests for complete banking workflows."""
def test_complete_banking_workflow(self):
"""Test complete user journey."""
# Setup
bank = Bank("Integration Test Bank")
# Create accounts
alice = bank.create_account("Alice", 10000.0, "1234")
bob = bank.create_account("Bob", 5000.0, "5678")
# Alice deposits more money
alice.deposit(5000.0)
assert alice.balance == 15000.0
# Alice transfers to Bob
alice.transfer(bob, 3000.0, "1234")
assert alice.balance == 12000.0
assert bob.balance == 8000.0
# Bob withdraws
bob.withdraw(2000.0, "5678")
assert bob.balance == 6000.0
# Verify transaction histories
assert len(alice.transaction_history) == 3 # deposit, transfer out
assert len(bob.transaction_history) == 2 # transfer in, withdrawal
def test_error_recovery_workflow(self):
"""Test system recovers properly from errors."""
bank = Bank("Error Test Bank")
account = bank.create_account("Test User", 1000.0, "1234")
# Failed withdrawal should not affect balance
with pytest.raises(InsufficientFundsError):
account.withdraw(5000.0, "1234")
assert account.balance == 1000.0
# Account should still be usable
account.deposit(500.0)
assert account.balance == 1500.0
Main Program with Error Demonstration
Create a main.py that demonstrates all features:
- Show proper exception handling in action
- Display logging output
- Run test suite programmatically
- Generate coverage report
def main():
"""Demonstrate the banking system with error handling."""
print("=" * 60)
print("SECUREBANK TESTING & DEBUGGING DEMONSTRATION")
print("=" * 60)
# Initialize bank
bank = Bank("SecureBank")
# Create accounts
print("\n1. Creating accounts...")
alice = bank.create_account("Alice Johnson", 10000.0, "1234")
bob = bank.create_account("Bob Smith", 5000.0, "5678")
# Demonstrate successful operations
print("\n2. Successful operations:")
print(f" Alice deposits ₹5000: Balance = ₹{alice.deposit(5000.0):.2f}")
print(f" Alice withdraws ₹2000: Balance = ₹{alice.withdraw(2000.0, '1234'):.2f}")
# Demonstrate exception handling
print("\n3. Exception handling demonstrations:")
# Invalid amount
try:
alice.deposit(-100)
except InvalidAmountError as e:
print(f" ✓ Caught InvalidAmountError: {e}")
# Insufficient funds
try:
bob.withdraw(50000.0, "5678")
except InsufficientFundsError as e:
print(f" ✓ Caught InsufficientFundsError: {e}")
# Wrong PIN
try:
alice.withdraw(100.0, "wrong")
except AuthenticationError as e:
print(f" ✓ Caught AuthenticationError: {e}")
print("\n4. Check log files:")
print(" - banking.log (all logs)")
print(" - banking_errors.log (errors only)")
print("\n" + "=" * 60)
print("Run 'pytest --cov' to execute test suite")
print("=" * 60)
if __name__ == "__main__":
main()
Submission
Create a public GitHub repository with the exact name shown below:
Required Repository Name
python-banking-testing
Required Files
python-banking-testing/
├── banking.py # Main banking classes (Account, Bank, SavingsAccount)
├── exceptions.py # Custom exception hierarchy
├── main.py # Demonstration script
├── pytest.ini # Pytest configuration
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Shared pytest fixtures
│ ├── test_account.py # Account tests (unittest)
│ ├── test_bank.py # Bank tests (pytest)
│ ├── test_exceptions.py # Exception tests
│ ├── test_integration.py # Integration tests
│ └── test_tdd.py # TDD feature tests
├── banking.log # Generated log file
├── banking_errors.log # Error log file
├── htmlcov/ # Coverage report (HTML)
├── output.txt # Output from main.py
└── README.md # Documentation
README.md Must Include:
- Your full name and submission date
- Exception hierarchy diagram
- Explanation of logging strategy
- TDD process documentation (red-green-refactor)
- Test coverage badge (90%+ required)
- Instructions to run tests
Do Include
- All 12 requirements implemented
- Custom exceptions with error codes
- Comprehensive logging at all levels
- Both unittest and pytest tests
- Mocking examples
- 90%+ test coverage
Do Not Include
- Unhandled exceptions
- Bare except clauses
- Print statements instead of logging
- Tests that don't pass
- Coverage below 90%
- Missing docstrings
pytest --cov --cov-fail-under=90 before submitting to ensure all tests pass
and coverage is at least 90%!
Enter your GitHub username - we'll verify your repository automatically
Grading Rubric
Your assignment will be graded on the following criteria:
| Criteria | Points | Description |
|---|---|---|
| Custom Exceptions (7.1) | 25 | Complete exception hierarchy with error codes, proper inheritance |
| Exception Handling (7.1) | 30 | Try/except/else/finally, multiple exceptions, exception chaining |
| Logging (7.2) | 30 | Multiple handlers, proper levels, formatted output, audit trail |
| Unit Tests (7.3) | 35 | unittest and pytest tests, fixtures, parametrize, mocking |
| TDD & Coverage (7.3) | 30 | TDD process documented, 90%+ coverage, integration tests |
| Code Quality | 25 | Docstrings, type hints, clean code, README documentation |
| Total | 175 |
What You Will Practice
Exception Handling (7.1)
Try/except/else/finally blocks, multiple exception handling, raising and re-raising, custom exception classes
Debugging & Logging (7.2)
Python logging module, log levels, handlers, formatters, debugging strategies
Unit Testing (7.3)
unittest framework, pytest, fixtures, parametrized tests, assertions, test organization
TDD & Mocking (7.3)
Test-driven development, red-green-refactor, mocking, patching, test coverage
Pro Tips
Exception Tips
- Never use bare
except:- always specify exception type - Use
raise ... from efor exception chaining - Custom exceptions should inherit from appropriate base
- Include helpful error messages and codes
Logging Tips
- Use appropriate log levels (DEBUG → CRITICAL)
- Include context in log messages (user, account)
- Log exceptions with
logger.exception() - Rotate log files in production
Testing Tips
- Test both success AND failure cases
- Use fixtures for common setup
- Parametrize tests for multiple inputs
- Keep tests independent and isolated
Common Mistakes
- Catching exceptions too broadly
- Not testing exception paths
- Forgetting to mock external dependencies
- Writing tests after code (not TDD)