1 The Problem
We want to model bank accounts: deposit, withdraw (but never below zero), transfer between accounts, and keep a transaction history. It teaches object-oriented modelling — representing real things as classes with data and rules that protect their own integrity.
2 How to Think About It
Think about what an account must guarantee, before any code:
3 The Build — explained part by part
Here is the complete account model as a class. Each part is explained below.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.history = []
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
self.history.append(("deposit", amount))
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
self.history.append(("withdraw", amount))
def transfer(self, other, amount):
# A transfer is a withdraw here plus a deposit there.
self.withdraw(amount)
other.deposit(amount)
self.history.append(("transfer_out", amount))
if __name__ == "__main__":
alice = Account("Alice", 100)
bob = Account("Bob")
alice.transfer(bob, 30)
print(f"Alice: {alice.balance}, Bob: {bob.balance}")
__init__(self, owner, balance=0) — the constructor: sets up a new account with an owner, a starting balance, and an empty history list.
deposit — adds money, but first checks the amount is positive (you cannot deposit a negative). It logs every change to
history.withdraw — the crucial rule:
if amount > self.balance: raise ValueError. The account refuses to go negative. This rule lives inside the class, so it can never be bypassed.transfer — built from the other two: withdraw here, deposit there. Because it reuses
withdraw, the insufficient-funds rule automatically applies to transfers too.raise ValueError(...) — signal a broken rule by raising an error, so the caller must deal with it rather than silently corrupting the balance.
withdraw and raise an error if insufficient.transfer — then the rule lives in two places.4 Test & Prove Each Part
We test that the account enforces its rules — especially that it refuses to overdraw.
import pytest
from bank import Account
def test_deposit():
"""Depositing increases the balance."""
a = Account("A")
a.deposit(50)
assert a.balance == 50
def test_withdraw():
"""Withdrawing within funds works."""
a = Account("A", 100)
a.withdraw(40)
assert a.balance == 60
def test_overdraw_refused():
"""Overdrawing raises an error and leaves the balance untouched."""
a = Account("A", 30)
with pytest.raises(ValueError):
a.withdraw(100)
assert a.balance == 30
def test_transfer():
"""Transfer moves money between accounts."""
a = Account("A", 100)
b = Account("B")
a.transfer(b, 30)
assert a.balance == 70
assert b.balance == 30
Run with pytest -v. The test_overdraw_refused test uses pytest.raises to prove the account rejects an illegal action and keeps its balance intact. Testing that bad actions are refused is as important as testing good ones work.
5 The Interface
On insufficient funds
ValueError: Insufficient funds6 Run It & Automate It
python3 bank.pySee a transfer in action. Try making it overdraw to watch the rule protect the balance.
Jenkins runs the tests automatically — each line explained below.
Alice: 70, Bob: 30// Jenkinsfile — automated tests on every change.
pipeline {
agent any
stages {
stage('Get the code') { steps { checkout scm } } // download the code
stage('Install') { steps { sh 'python3 -m venv venv && . venv/bin/activate && pip install pytest' } } // test tool
stage('Test') { steps { sh '. venv/bin/activate && pytest -v' } } // run every test
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Interest. Add a method that applies interest to the balance. (Teaches: more behaviour.)
- Statement. Print the full transaction history nicely. (Teaches: using the history.)
- Account types. Make a SavingsAccount subclass with withdrawal limits. (Teaches: inheritance.)