Project 01 - Full Test Suite for a Banking System
Estimated time: 5–7 hours core | Level: Intermediate
Before reading the requirements, consider this question: you have written a withdraw() method for a bank account. You write a test that calls withdraw(100) when the balance is 500 and checks the balance becomes 400. What are the 6 other cases you have not tested yet?
Most engineers list 2 or 3. Hypothesis will find all 6 - and a few you did not think were cases at all.
Learning Objectives
By completing this project you will have practiced:
- Writing pytest fixtures at three scopes: function, class, and session
- Using parametrized tests to cover edge cases without duplicating test logic
- Mocking external dependencies (notification service, audit logger) so tests are deterministic and fast
- Writing integration tests that test multiple components interacting
- Using
hypothesisto define invariants and let the framework generate test cases - Measuring and enforcing 90% branch coverage with
pytest-cov - Writing a performance test that enforces a timing requirement
- Organizing a test suite the way production test suites are organized
The System Under Test
You are testing the BankAccount, SavingsAccount, and TransactionHistory classes from Module 01 Project 01. If you did not complete that project, the starter implementation below is sufficient:
# bank/account.py
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from typing import Protocol
class NotificationService(Protocol):
def send(self, account_id: str, message: str) -> None: ...
class AuditLogger(Protocol):
def log(self, account_id: str, action: str, amount: Decimal) -> None: ...
@dataclass
class Transaction:
transaction_id: str
action: str # "deposit", "withdrawal", "transfer_in", "transfer_out"
amount: Decimal
timestamp: datetime
balance_after: Decimal
class TransactionHistory:
def __init__(self) -> None:
self._transactions: list[Transaction] = []
def record(self, action: str, amount: Decimal, balance_after: Decimal) -> Transaction:
tx = Transaction(
transaction_id=str(uuid.uuid4()),
action=action,
amount=amount,
timestamp=datetime.now(),
balance_after=balance_after,
)
self._transactions.append(tx)
return tx
def all(self) -> list[Transaction]:
return list(self._transactions)
def last(self) -> Transaction | None:
return self._transactions[-1] if self._transactions else None
def total_deposited(self) -> Decimal:
return sum(
(t.amount for t in self._transactions if t.action == "deposit"),
start=Decimal("0"),
)
def total_withdrawn(self) -> Decimal:
return sum(
(t.amount for t in self._transactions if t.action == "withdrawal"),
start=Decimal("0"),
)
class BankAccount:
def __init__(
self,
owner: str,
initial_balance: Decimal = Decimal("0"),
notification_service: NotificationService | None = None,
audit_logger: AuditLogger | None = None,
) -> None:
if initial_balance < Decimal("0"):
raise ValueError("Initial balance cannot be negative")
self.account_id: str = str(uuid.uuid4())
self.owner: str = owner
self._balance: Decimal = initial_balance
self._history: TransactionHistory = TransactionHistory()
self._notification_service = notification_service
self._audit_logger = audit_logger
@property
def balance(self) -> Decimal:
return self._balance
def deposit(self, amount: Decimal) -> Transaction:
if amount <= Decimal("0"):
raise ValueError(f"Deposit amount must be positive, got {amount}")
self._balance += amount
tx = self._history.record("deposit", amount, self._balance)
if self._audit_logger:
self._audit_logger.log(self.account_id, "deposit", amount)
if self._notification_service:
self._notification_service.send(
self.account_id,
f"Deposit of {amount} received. New balance: {self._balance}",
)
return tx
def withdraw(self, amount: Decimal) -> Transaction:
if amount <= Decimal("0"):
raise ValueError(f"Withdrawal amount must be positive, got {amount}")
if amount > self._balance:
raise ValueError(
f"Insufficient funds: balance {self._balance}, requested {amount}"
)
self._balance -= amount
tx = self._history.record("withdrawal", amount, self._balance)
if self._audit_logger:
self._audit_logger.log(self.account_id, "withdrawal", amount)
return tx
@property
def history(self) -> TransactionHistory:
return self._history
def __repr__(self) -> str:
return f"BankAccount(owner={self.owner!r}, balance={self._balance})"
class SavingsAccount(BankAccount):
MIN_BALANCE = Decimal("100")
INTEREST_RATE = Decimal("0.05") # 5% annual
def __init__(
self,
owner: str,
initial_balance: Decimal = Decimal("100"),
**kwargs,
) -> None:
if initial_balance < self.MIN_BALANCE:
raise ValueError(
f"SavingsAccount minimum initial balance is {self.MIN_BALANCE}"
)
super().__init__(owner, initial_balance, **kwargs)
def withdraw(self, amount: Decimal) -> Transaction:
if self._balance - amount < self.MIN_BALANCE:
raise ValueError(
f"Withdrawal would drop balance below minimum {self.MIN_BALANCE}"
)
return super().withdraw(amount)
def apply_interest(self) -> Decimal:
"""Apply annual interest. Returns interest amount credited."""
interest = (self._balance * self.INTEREST_RATE).quantize(Decimal("0.01"))
self.deposit(interest)
return interest
Requirements
R1 - Full pytest test suite structure
Organize tests as follows. Create all files:
tests/
conftest.py ← shared fixtures
unit/
test_bank_account.py ← BankAccount unit tests
test_savings_account.py ← SavingsAccount unit tests
test_transaction_history.py ← TransactionHistory unit tests
integration/
test_transfers.py ← multi-account transfer integration tests
property/
test_invariants.py ← Hypothesis property-based tests
performance/
test_throughput.py ← timing requirements
Every test must use pytest style (functions starting with test_, not classes inheriting from unittest.TestCase).
R2 - Fixtures for account setup, pre-loaded history, and mock time
In conftest.py, define the following fixtures:
empty_account (function scope): a BankAccount for "Alice" with zero balance, no external dependencies.
funded_account (function scope): a BankAccount for "Bob" with Decimal("1000") balance.
account_with_history (function scope): a BankAccount with 5 deposits and 3 withdrawals pre-recorded in the transaction history.
mock_notification_service (function scope): a MagicMock configured to satisfy the NotificationService protocol. The send method is a mock.
mock_audit_logger (function scope): a MagicMock configured to satisfy the AuditLogger protocol. The log method is a mock.
instrumented_account (function scope): a BankAccount wired with both mock_notification_service and mock_audit_logger. Use fixture injection (request both mocks as arguments to this fixture).
frozen_time (function scope): a fixture that patches datetime.now to return a fixed datetime 2024-01-15 10:30:00. Use unittest.mock.patch inside the fixture. This ensures transaction timestamps are deterministic in tests that assert on them.
savings_account (function scope): a SavingsAccount for "Carol" with Decimal("500") initial balance.
R3 - Parametrized tests for edge cases
In test_bank_account.py, write the following parametrized test:
@pytest.mark.parametrize("deposit_amount,expected_balance", [
# normal cases
(Decimal("100"), Decimal("100")),
(Decimal("0.01"), Decimal("0.01")),
(Decimal("1000000"), Decimal("1000000")),
# ... add more
])
def test_deposit_sets_correct_balance(empty_account, deposit_amount, expected_balance):
...
Write parametrized tests covering:
test_deposit_sets_correct_balance: at least 5 cases - normal amounts, very small amounts (0.01), very large amounts, penny amounts.
test_deposit_raises_on_invalid_amount: at least 4 cases - zero, negative, very negative, negative float-like decimal. Each should raise ValueError.
test_withdraw_raises_on_invalid_amount: at least 5 cases - zero, negative, amount equal to balance + 0.01, amount equal to balance × 2, negative.
test_savings_withdraw_raises_below_minimum: at least 3 cases where withdrawal would drop below MIN_BALANCE. Each should raise ValueError.
test_savings_withdraw_allows_up_to_minimum: at least 3 cases where withdrawal leaves exactly MIN_BALANCE - these should succeed.
R4 - Mocked external dependencies
In test_bank_account.py, using the instrumented_account fixture:
test_notification_sent_on_deposit: verify that mock_notification_service.send is called exactly once after deposit(), with the correct account ID and a message that contains the deposit amount.
test_notification_not_sent_on_failed_deposit: verify that mock_notification_service.send is not called when deposit() raises (e.g., deposit of zero).
test_audit_logged_on_deposit: verify that mock_audit_logger.log is called with (account_id, "deposit", amount).
test_audit_logged_on_withdrawal: verify that mock_audit_logger.log is called with (account_id, "withdrawal", amount).
test_notification_not_sent_on_withdrawal: verify that the notification service is NOT called on a successful withdrawal (per the implementation above, only deposits trigger notifications).
test_audit_logged_correct_number_of_times: make 3 deposits and 2 withdrawals. Verify mock_audit_logger.log was called exactly 5 times total.
R5 - Integration tests for multi-account transfers
Create tests/integration/test_transfers.py. Implement a transfer(source, destination, amount) function in bank/account.py (or as a standalone function) that:
- Withdraws
amountfromsource - Deposits
amountintodestination - Is atomic: if the withdrawal succeeds but the deposit fails, the withdrawal is reversed
Write tests:
test_transfer_moves_funds: transfer Decimal("200") from a funded_account to an empty_account. Assert that source balance decreases by 200 and destination balance increases by 200.
test_transfer_records_in_both_histories: after transfer, assert that source history contains a "withdrawal" and destination history contains a "deposit" with the transferred amount.
test_transfer_fails_on_insufficient_funds: attempt to transfer more than source balance. Assert ValueError is raised. Assert both balances are unchanged.
test_transfer_atomicity: simulate a failure in the deposit step (by patching BankAccount.deposit to raise after the withdrawal has succeeded). Assert the source balance is restored to its pre-transfer value (the reversal happened).
test_concurrent_transfer_does_not_corrupt_balance: make 10 transfers of Decimal("10") from account A to account B sequentially. Assert total of A + B remains constant (conservation of funds).
R6 - Coverage enforcement
Add a pytest.ini or pyproject.toml configuration that runs coverage automatically:
[tool.pytest.ini_options]
addopts = "--cov=bank --cov-report=term-missing --cov-report=html --cov-branch --cov-fail-under=90"
testpaths = ["tests"]
markers = [
"slow: marks tests as slow (deselect with '-m not slow')",
"integration: marks tests as integration tests",
"property: marks tests as property-based tests",
]
Run pytest and achieve at least 90% branch coverage. The --cov-fail-under=90 flag makes the test run exit with code 1 if coverage drops below 90%, enabling enforcement in CI.
To see exactly which branches are missed:
pytest --cov=bank --cov-report=term-missing --cov-branch
# Look for lines showing "Missing" - these are uncovered branches
R7 - Property-based tests with Hypothesis
Install Hypothesis:
pip install hypothesis
In tests/property/test_invariants.py, write the following property-based tests:
Invariant 1: Balance is never negative after valid operations
from hypothesis import given, strategies as st
from decimal import Decimal
@given(
initial=st.decimals(min_value=Decimal("0"), max_value=Decimal("10000"),
allow_nan=False, allow_infinity=False),
deposits=st.lists(
st.decimals(min_value=Decimal("0.01"), max_value=Decimal("1000"),
allow_nan=False, allow_infinity=False),
min_size=0, max_size=10,
),
)
def test_balance_never_negative_after_valid_deposits(initial, deposits):
account = BankAccount("Test", initial)
for amount in deposits:
account.deposit(amount)
assert account.balance >= Decimal("0")
Invariant 2: Total deposited equals sum of deposits
Define a strategy for a list of valid deposit amounts. Create a fresh account, deposit all amounts, and assert history.total_deposited() equals the sum of the deposit amounts. Handle floating-point precision by using Decimal strategies throughout.
Invariant 3: Withdrawal never produces negative balance
Generate a funded account and a series of valid withdrawals (each smaller than the remaining balance). Assert the balance is never negative at any point and equals the initial balance minus all withdrawals at the end.
Invariant 4: Conservation of funds under transfer
Generate two accounts with initial balances a and b. Generate a transfer amount t where 0 < t <= a. After transferring t from account A to account B, assert account_a.balance + account_b.balance == a + b (no funds created or destroyed).
Invariant 5: Interest application increases balance
For a SavingsAccount, after calling apply_interest(), the balance must be strictly greater than before. The interest amount returned must equal (old_balance * 0.05).quantize(Decimal("0.01")).
Hypothesis uses a database to remember which inputs caused failures in past runs. It will re-test those specific inputs on every future run before generating new ones. This means once Hypothesis finds a bug, it remembers it forever - the bug cannot be silently reintroduced without Hypothesis catching it.
The @settings decorator controls Hypothesis behaviour:
from hypothesis import given, settings, HealthCheck
@given(...)
@settings(max_examples=500, suppress_health_check=[HealthCheck.too_slow])
def test_invariant(...):
...
The default max_examples=100 is fine for most tests. For invariants you are especially confident about, use max_examples=1000 for extra assurance.
R8 - Performance test: 10,000 transactions in under 1 second
In tests/performance/test_throughput.py:
import time
import pytest
from decimal import Decimal
from bank.account import BankAccount
@pytest.mark.slow
def test_10000_transactions_under_one_second():
account = BankAccount("PerfTest", Decimal("1000000"))
start = time.perf_counter()
for i in range(5000):
account.deposit(Decimal("1"))
account.withdraw(Decimal("1"))
elapsed = time.perf_counter() - start
assert elapsed < 1.0, (
f"10,000 transactions took {elapsed:.3f}s - expected under 1.0s. "
f"The BankAccount implementation is too slow for production use."
)
# Verify correctness: all deposits and withdrawals cancelled out
assert account.balance == Decimal("1000000")
assert account.history.total_deposited() == Decimal("5000")
assert account.history.total_withdrawn() == Decimal("5000")
Mark this test @pytest.mark.slow so it can be excluded from fast test runs:
# Fast runs (skip performance tests)
pytest -m "not slow"
# Full runs including performance
pytest
Starter Fixtures (conftest.py)
# tests/conftest.py
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
from bank.account import BankAccount, SavingsAccount
@pytest.fixture
def empty_account() -> BankAccount:
return BankAccount(owner="Alice", initial_balance=Decimal("0"))
@pytest.fixture
def funded_account() -> BankAccount:
return BankAccount(owner="Bob", initial_balance=Decimal("1000"))
@pytest.fixture
def account_with_history() -> BankAccount:
account = BankAccount(owner="History User", initial_balance=Decimal("0"))
# Pre-load with known transaction history
for amount in [Decimal("100"), Decimal("200"), Decimal("50"), Decimal("300"), Decimal("75")]:
account.deposit(amount)
for amount in [Decimal("50"), Decimal("100"), Decimal("25")]:
account.withdraw(amount)
return account
@pytest.fixture
def mock_notification_service() -> MagicMock:
mock = MagicMock()
mock.send = MagicMock(return_value=None)
return mock
@pytest.fixture
def mock_audit_logger() -> MagicMock:
mock = MagicMock()
mock.log = MagicMock(return_value=None)
return mock
@pytest.fixture
def instrumented_account(
mock_notification_service: MagicMock,
mock_audit_logger: MagicMock,
) -> BankAccount:
return BankAccount(
owner="Instrumented",
initial_balance=Decimal("500"),
notification_service=mock_notification_service,
audit_logger=mock_audit_logger,
)
@pytest.fixture
def frozen_time():
"""Patch datetime.now to return a fixed datetime."""
fixed_dt = __import__("datetime").datetime(2024, 1, 15, 10, 30, 0)
with patch("bank.account.datetime") as mock_dt:
mock_dt.now.return_value = fixed_dt
yield fixed_dt
@pytest.fixture
def savings_account() -> SavingsAccount:
return SavingsAccount(owner="Carol", initial_balance=Decimal("500"))
Expected Coverage Report (After Completing All Tests)
---------- coverage: platform linux, python 3.11 ----------
Name Stmts Miss Branch BrPart Cover
---------------------------------------------------------------
bank/__init__.py 0 0 0 0 100%
bank/account.py 98 4 42 3 94%
---------------------------------------------------------------
TOTAL 98 4 42 3 94%
4 statements and 3 branch points missed. Run with --cov-report=term-missing
to see exact lines.
If you are below 90%, look at the "Missing" column in --cov-report=term-missing output. Common missed branches:
- The
if self._notification_service:guard indeposit()- you need a test with no notification service AND a test with one - The
if self._audit_logger:guard - same pattern - The
if not self._transactions:inTransactionHistory.last()- test an empty history - Error paths in
withdraw()- test the exactly-zero case and the over-balance case
Step-by-Step Hints
Hint 1 - Fixture scoping strategy
Use function scope (the default) for all account fixtures. Each test gets a fresh account with no shared state. If you used session or module scope, tests would affect each other through shared balance mutations - a classic testing anti-pattern called "test pollution."
Only use session scope for fixtures that are expensive to create and provably read-only: database connections, compiled regex patterns, loaded ML models.
Hint 2 - Testing that mock was called with correct arguments
# Check called once
mock_notification_service.send.assert_called_once()
# Check called with specific arguments
mock_notification_service.send.assert_called_once_with(
account.account_id,
unittest.mock.ANY, # any string for the message
)
# Check message contains the amount
call_args = mock_notification_service.send.call_args
message = call_args[0][1] # second positional argument
assert "100" in message
# Check NOT called
mock_notification_service.send.assert_not_called()
# Check called N times
assert mock_audit_logger.log.call_count == 5
Hint 3 - Testing for raised exceptions with parametrize
@pytest.mark.parametrize("amount", [
Decimal("0"),
Decimal("-1"),
Decimal("-100"),
Decimal("-0.01"),
])
def test_deposit_raises_on_invalid_amount(empty_account, amount):
with pytest.raises(ValueError):
empty_account.deposit(amount)
Hint 4 - Transfer atomicity test
To test that a failed deposit is reversed:
def test_transfer_atomicity(funded_account, empty_account):
original_balance = funded_account.balance
with patch.object(empty_account, "deposit", side_effect=RuntimeError("deposit failed")):
with pytest.raises(RuntimeError):
transfer(funded_account, empty_account, Decimal("100"))
# The withdrawal was reversed
assert funded_account.balance == original_balance
Hint 5 - Hypothesis Decimal strategies
st.decimals() can produce NaN and Infinity by default. Always use allow_nan=False, allow_infinity=False. For bank amounts, also restrict to a sensible range and quantize to 2 decimal places:
valid_amount = st.decimals(
min_value=Decimal("0.01"),
max_value=Decimal("100000"),
allow_nan=False,
allow_infinity=False,
places=2, # force 2 decimal places
)
Hint 6 - Running only fast tests during development
# Skip slow (performance) tests for fast development iteration
pytest -m "not slow" --tb=short
# Run only property tests
pytest tests/property/ -v
# Run only integration tests
pytest tests/integration/ -v
# Full suite including slow tests
pytest
What You Are Proving
| Test type | What it proves |
|---|---|
| Unit + fixtures | Each method works correctly in isolation with controlled state |
| Parametrized | Edge cases are systematically covered, not ad hoc |
| Mocked dependencies | The class is testable in isolation; external calls are verifiable |
| Integration | Components interact correctly; the seams between classes work |
| Hypothesis | Invariants hold for arbitrary valid inputs, not just hand-picked ones |
| Performance | The implementation is efficient enough for production load |
| Coverage gate | There are no untested branches that could harbor silent bugs |
A test suite that passes all eight dimensions is not proof of correctness - but it is the engineering-grade evidence that the code behaves as specified under a wide range of conditions.
Extension Challenges
Extension 1 - Test concurrent access
Use threading to run 10 threads simultaneously, each making 100 deposits and 100 withdrawals to the same account. Assert that after all threads complete, the balance equals the initial balance (net deposits = net withdrawals). Does BankAccount pass? If not, what needs to change?
Extension 2 - Snapshot testing for transaction history
Use pytest-snapshot or serialize the TransactionHistory to JSON and compare against a stored snapshot file. Useful for detecting accidental changes to the data format returned by history.all().
Extension 3 - Mutation testing
Install mutmut and run it against bank/account.py:
pip install mutmut
mutmut run --paths-to-mutate bank/account.py
mutmut results
mutmut introduces small bugs (mutations) into your source code one at a time, then runs your test suite. If your tests still pass with a bug present, you have a gap in your coverage. The goal: 0 surviving mutants.
Extension 4 - Database-backed accounts
Replace the in-memory _transactions list with a SQLite database using sqlite3. Write tests that:
- Use a
tmp_pathpytest fixture for a temporary database file - Test that transactions survive a simulated "restart" (close and reopen the database)
- Test that concurrent access from two connections does not corrupt the data
