commit df1f0fa1c9ae65ea226d0099a8d4b1fa391e48c6 Author: MelvynFaith Date: Mon Oct 26 14:24:33 2026 +0700 initial commit diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..965e3eb Binary files /dev/null and b/.coverage differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..20be60f --- /dev/null +++ b/README.md @@ -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. diff --git a/__pycache__/banking.cpython-314.pyc b/__pycache__/banking.cpython-314.pyc new file mode 100644 index 0000000..0b4ca88 Binary files /dev/null and b/__pycache__/banking.cpython-314.pyc differ diff --git a/__pycache__/notification_service.cpython-314.pyc b/__pycache__/notification_service.cpython-314.pyc new file mode 100644 index 0000000..2013484 Binary files /dev/null and b/__pycache__/notification_service.cpython-314.pyc differ diff --git a/banking.py b/banking.py new file mode 100644 index 0000000..590f154 --- /dev/null +++ b/banking.py @@ -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 diff --git a/coverage_analysis.md b/coverage_analysis.md new file mode 100644 index 0000000..15f1fee --- /dev/null +++ b/coverage_analysis.md @@ -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. diff --git a/coverage_report.txt b/coverage_report.txt new file mode 100644 index 0000000..b323bee --- /dev/null +++ b/coverage_report.txt @@ -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 ============================== diff --git a/docs/Laporan_PyTest_Sesi_9.docx b/docs/Laporan_PyTest_Sesi_9.docx new file mode 100644 index 0000000..e4f922e Binary files /dev/null and b/docs/Laporan_PyTest_Sesi_9.docx differ diff --git a/docs/Laporan_PyTest_Sesi_9.pdf b/docs/Laporan_PyTest_Sesi_9.pdf new file mode 100644 index 0000000..4bdcba7 Binary files /dev/null and b/docs/Laporan_PyTest_Sesi_9.pdf differ diff --git a/docs/~$poran_PyTest_Sesi_9.docx b/docs/~$poran_PyTest_Sesi_9.docx new file mode 100644 index 0000000..3629826 Binary files /dev/null and b/docs/~$poran_PyTest_Sesi_9.docx differ diff --git a/notification_service.py b/notification_service.py new file mode 100644 index 0000000..32bf9f7 --- /dev/null +++ b/notification_service.py @@ -0,0 +1,3 @@ +def send_notification(recipient, message): + """Placeholder for an external notification service.""" + raise RuntimeError("External notification service is not configured") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e2c5a32 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -v diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c61acea --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pytest>=8,<10 +pytest-cov>=5,<8 diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..5d56d1d Binary files /dev/null and b/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_banking.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_banking.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..e340058 Binary files /dev/null and b/tests/__pycache__/test_banking.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..263ea8b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from banking import BankAccount + + +@pytest.fixture +def account(): + instance = BankAccount("Alice", 100_000) + yield instance + instance.balance = 0 diff --git a/tests/test_banking.py b/tests/test_banking.py new file mode 100644 index 0000000..aa5caad --- /dev/null +++ b/tests/test_banking.py @@ -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)