initial commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# Session 9 - Banking Unit Tests (PyTest)
|
||||
|
||||
A small local Python project aligned with the Session 9 lecture. It includes conditional banking functions, BVA parametrization, negative tests with `pytest.raises`, a `yield` fixture in `conftest.py`, and a mock of the external notification dependency.
|
||||
|
||||
## Run locally
|
||||
|
||||
From the project folder:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
Activate the virtual environment:
|
||||
|
||||
- Windows PowerShell: `.venv\Scripts\Activate.ps1`
|
||||
- macOS/Linux: `source .venv/bin/activate`
|
||||
|
||||
Install dependencies and run tests:
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pytest -v
|
||||
python -m pytest --cov=banking --cov-branch --cov-report=term-missing --cov-report=html
|
||||
```
|
||||
|
||||
Open `htmlcov/index.html` for the detailed coverage report. The included `coverage_report.txt` contains a verified sample run. No deployment, database, or real notification service is needed.
|
||||
|
||||
## Lecture checklist
|
||||
|
||||
- Three conditional operations: `deposit`, `withdraw`, `transfer` (plus constructor validation)
|
||||
- At least eight test cases, including parametrized boundary values
|
||||
- Multiple negative `pytest.raises` tests
|
||||
- `yield` fixture with cleanup in `tests/conftest.py`
|
||||
- `unittest.mock.patch` on `banking.send_notification` (where it is used)
|
||||
- Statement and branch coverage with a short analysis
|
||||
|
||||
Amounts use integer units to avoid floating-point money rounding in this introductory example. `notification_service.py` deliberately raises if called directly: unit tests mock this external dependency. The transfer example is in-memory and not intended as a production banking implementation.
|
||||
Binary file not shown.
Binary file not shown.
+37
@@ -0,0 +1,37 @@
|
||||
from notification_service import send_notification
|
||||
|
||||
|
||||
class BankAccount:
|
||||
def __init__(self, owner, balance=0):
|
||||
if not owner.strip():
|
||||
raise ValueError("Owner is required")
|
||||
if balance < 0:
|
||||
raise ValueError("Initial balance cannot be negative")
|
||||
self.owner = owner
|
||||
self.balance = balance
|
||||
|
||||
def deposit(self, amount):
|
||||
if amount <= 0:
|
||||
raise ValueError("Deposit must be positive")
|
||||
self.balance += amount
|
||||
return self.balance
|
||||
|
||||
def withdraw(self, amount):
|
||||
if amount <= 0:
|
||||
raise ValueError("Withdrawal must be positive")
|
||||
if amount > self.balance:
|
||||
raise ValueError("Insufficient funds")
|
||||
self.balance -= amount
|
||||
return self.balance
|
||||
|
||||
def transfer(self, recipient, amount):
|
||||
if recipient is self:
|
||||
raise ValueError("Cannot transfer to the same account")
|
||||
if amount <= 0:
|
||||
raise ValueError("Transfer must be positive")
|
||||
if amount > self.balance:
|
||||
raise ValueError("Insufficient funds")
|
||||
self.balance -= amount
|
||||
recipient.balance += amount
|
||||
send_notification(recipient.owner, f"Received {amount} from {self.owner}")
|
||||
return self.balance
|
||||
@@ -0,0 +1,9 @@
|
||||
# Coverage report and analysis
|
||||
|
||||
Command: `python -m pytest --cov=banking --cov-branch --cov-report=term-missing --cov-report=html`
|
||||
|
||||
Verified run: **17 passed**. `banking.py`: 32 statements, 0 missed; 16 branches, 0 partial; total coverage **100%**.
|
||||
|
||||
No conditional branches in `banking.py` remain uncovered. The tests exercise valid operations, boundary values, invalid amounts, insufficient funds, self-transfer, and invalid constructor inputs. The notification is patched at `banking.send_notification`, so the tests verify its invocation without calling the real external dependency. `notification_service.py` is intentionally excluded from `--cov=banking` because this is a unit test of the banking module, not an integration test of the external service.
|
||||
|
||||
Coverage measures execution, not proof of correctness; production banking would require additional integration, persistence, concurrency, and transaction-failure tests.
|
||||
@@ -0,0 +1,39 @@
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.5, pytest-9.0.2, pluggy-1.6.0 -- /opt/pyvenv/bin/python
|
||||
cachedir: .pytest_cache
|
||||
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.18.55-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.0.2', 'pluggy': '1.6.0'}, 'Plugins': {'anyio': '4.13.0', 'ddtrace': '4.4.0', 'Faker': '40.1.2', 'asyncio': '1.3.0', 'cov': '7.0.0', 'json-report': '1.5.0', 'metadata': '3.1.1'}, 'PLATFORM': 'linux/amd64', 'CI': 'true'}
|
||||
rootdir: /mnt/data/banking_pytest_session9
|
||||
configfile: pytest.ini
|
||||
testpaths: tests
|
||||
plugins: anyio-4.13.0, ddtrace-4.4.0, Faker-40.1.2, asyncio-1.3.0, cov-7.0.0, json-report-1.5.0, metadata-3.1.1
|
||||
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 17 items
|
||||
|
||||
tests/test_banking.py::test_deposit_bva[1-100001] PASSED [ 5%]
|
||||
tests/test_banking.py::test_deposit_bva[99999-199999] PASSED [ 11%]
|
||||
tests/test_banking.py::test_deposit_bva[100000-200000] PASSED [ 17%]
|
||||
tests/test_banking.py::test_deposit_bva[100001-200001] PASSED [ 23%]
|
||||
tests/test_banking.py::test_deposit_zero_raises PASSED [ 29%]
|
||||
tests/test_banking.py::test_deposit_negative_raises PASSED [ 35%]
|
||||
tests/test_banking.py::test_withdraw_valid_boundaries[1-99999] PASSED [ 41%]
|
||||
tests/test_banking.py::test_withdraw_valid_boundaries[99999-1] PASSED [ 47%]
|
||||
tests/test_banking.py::test_withdraw_valid_boundaries[100000-0] PASSED [ 52%]
|
||||
tests/test_banking.py::test_withdraw_zero_raises PASSED [ 58%]
|
||||
tests/test_banking.py::test_withdraw_over_balance_raises PASSED [ 64%]
|
||||
tests/test_banking.py::test_transfer_and_notification_mock PASSED [ 70%]
|
||||
tests/test_banking.py::test_transfer_insufficient_funds_does_not_notify PASSED [ 76%]
|
||||
tests/test_banking.py::test_transfer_zero_raises PASSED [ 82%]
|
||||
tests/test_banking.py::test_transfer_to_self_raises PASSED [ 88%]
|
||||
tests/test_banking.py::test_blank_owner_raises PASSED [ 94%]
|
||||
tests/test_banking.py::test_negative_initial_balance_raises PASSED [100%]
|
||||
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.5-final-0 ________________
|
||||
|
||||
Name Stmts Miss Branch BrPart Cover Missing
|
||||
--------------------------------------------------------
|
||||
banking.py 32 0 16 0 100%
|
||||
--------------------------------------------------------
|
||||
TOTAL 32 0 16 0 100%
|
||||
Coverage HTML written to dir htmlcov
|
||||
============================== 17 passed in 0.13s ==============================
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
def send_notification(recipient, message):
|
||||
"""Placeholder for an external notification service."""
|
||||
raise RuntimeError("External notification service is not configured")
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -v
|
||||
@@ -0,0 +1,2 @@
|
||||
pytest>=8,<10
|
||||
pytest-cov>=5,<8
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
import pytest
|
||||
from banking import BankAccount
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account():
|
||||
instance = BankAccount("Alice", 100_000)
|
||||
yield instance
|
||||
instance.balance = 0
|
||||
@@ -0,0 +1,81 @@
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from banking import BankAccount
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount, expected", [
|
||||
(1, 100_001),
|
||||
(99_999, 199_999),
|
||||
(100_000, 200_000),
|
||||
(100_001, 200_001),
|
||||
])
|
||||
def test_deposit_bva(account, amount, expected):
|
||||
assert account.deposit(amount) == expected
|
||||
|
||||
|
||||
def test_deposit_zero_raises(account):
|
||||
with pytest.raises(ValueError, match="Deposit must be positive"):
|
||||
account.deposit(0)
|
||||
|
||||
|
||||
def test_deposit_negative_raises(account):
|
||||
with pytest.raises(ValueError, match="Deposit must be positive"):
|
||||
account.deposit(-1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount, expected", [
|
||||
(1, 99_999),
|
||||
(99_999, 1),
|
||||
(100_000, 0),
|
||||
])
|
||||
def test_withdraw_valid_boundaries(account, amount, expected):
|
||||
assert account.withdraw(amount) == expected
|
||||
|
||||
|
||||
def test_withdraw_zero_raises(account):
|
||||
with pytest.raises(ValueError, match="Withdrawal must be positive"):
|
||||
account.withdraw(0)
|
||||
|
||||
|
||||
def test_withdraw_over_balance_raises(account):
|
||||
with pytest.raises(ValueError, match="Insufficient funds"):
|
||||
account.withdraw(100_001)
|
||||
assert account.balance == 100_000
|
||||
|
||||
|
||||
def test_transfer_and_notification_mock(account):
|
||||
recipient = BankAccount("Bob", 10_000)
|
||||
with patch("banking.send_notification") as mocked:
|
||||
assert account.transfer(recipient, 40_000) == 60_000
|
||||
assert recipient.balance == 50_000
|
||||
mocked.assert_called_once_with("Bob", "Received 40000 from Alice")
|
||||
|
||||
|
||||
def test_transfer_insufficient_funds_does_not_notify(account):
|
||||
recipient = BankAccount("Bob")
|
||||
with patch("banking.send_notification") as mocked:
|
||||
with pytest.raises(ValueError, match="Insufficient funds"):
|
||||
account.transfer(recipient, 100_001)
|
||||
mocked.assert_not_called()
|
||||
assert account.balance == 100_000
|
||||
assert recipient.balance == 0
|
||||
|
||||
|
||||
def test_transfer_zero_raises(account):
|
||||
with pytest.raises(ValueError, match="Transfer must be positive"):
|
||||
account.transfer(BankAccount("Bob"), 0)
|
||||
|
||||
|
||||
def test_transfer_to_self_raises(account):
|
||||
with pytest.raises(ValueError, match="Cannot transfer to the same account"):
|
||||
account.transfer(account, 100)
|
||||
|
||||
|
||||
def test_blank_owner_raises():
|
||||
with pytest.raises(ValueError, match="Owner is required"):
|
||||
BankAccount(" ")
|
||||
|
||||
|
||||
def test_negative_initial_balance_raises():
|
||||
with pytest.raises(ValueError, match="Initial balance cannot be negative"):
|
||||
BankAccount("Alice", -1)
|
||||
Reference in New Issue
Block a user