146 lines
4.9 KiB
Markdown
146 lines
4.9 KiB
Markdown
# Testing Guide
|
|
|
|
## Overview
|
|
|
|
The project uses Python's built-in `unittest` framework with `unittest.mock` for mocking external dependencies (Google Sheets API, attendance data). Tests live in the `tests/` directory.
|
|
|
|
## Running Tests
|
|
|
|
```bash
|
|
make test # Run all tests
|
|
make test-v # Run with verbose output
|
|
```
|
|
|
|
Under the hood:
|
|
```bash
|
|
PYTHONPATH=scripts:. python3 -m unittest discover tests
|
|
```
|
|
|
|
The `PYTHONPATH` includes both `scripts/` and the project root so that test files can import from both `app.py` and `scripts/*.py`.
|
|
|
|
## Test Files
|
|
|
|
### `test_app.py` — Flask Route Tests
|
|
|
|
Tests the Flask web application routes using Flask's built-in test client. All external data fetching is mocked.
|
|
|
|
| Test | What it verifies |
|
|
|------|-----------------|
|
|
| `test_index_page` | `GET /` returns 200 and contains a redirect to `/fees` |
|
|
| `test_fees_route` | `GET /fees` renders the fees dashboard with correct member names |
|
|
| `test_reconcile_route` | `GET /reconcile` renders the reconciliation page with payment matching |
|
|
| `test_payments_route` | `GET /payments` renders the ledger with grouped transactions |
|
|
|
|
**Mocking strategy**:
|
|
|
|
```python
|
|
@patch('app.get_members_with_fees')
|
|
def test_fees_route(self, mock_get_members):
|
|
mock_get_members.return_value = (
|
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
|
['2026-01']
|
|
)
|
|
response = self.client.get('/fees')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(b'Test Member', response.data)
|
|
```
|
|
|
|
Each test patches the data-fetching functions (`get_members_with_fees`, `fetch_sheet_data`) to return controlled test data, avoiding any network calls.
|
|
|
|
**Notable**: The reconcile route test also mocks `fetch_sheet_data` and verifies that the reconciliation engine correctly matches a payment against an expected fee (checking for "OK" in the response).
|
|
|
|
### `test_reconcile_exceptions.py` — Reconciliation Logic Tests
|
|
|
|
Tests the `reconcile()` function directly (unit tests for the core business logic):
|
|
|
|
| Test | What it verifies |
|
|
|------|-----------------|
|
|
| `test_reconcile_applies_exceptions` | When a fee exception exists (400 CZK instead of 750), the expected amount is overridden and balance is calculated correctly |
|
|
| `test_reconcile_fallback_to_attendance` | When no exception exists, the attendance-based fee is used |
|
|
|
|
**Why these tests matter**: The exception system is critical for correctness — an incorrect override could cause members to be shown incorrect amounts owed. These tests verify that:
|
|
- Exceptions properly override the attendance-based fee
|
|
- The absence of an exception correctly falls back to the standard calculation
|
|
- Balances are computed correctly against overridden amounts
|
|
|
|
## Test Data Patterns
|
|
|
|
The tests use minimal but representative data:
|
|
|
|
```python
|
|
# A member with attendance-based fee
|
|
members = [('Alice', 'A', {'2026-01': (750, 4)})]
|
|
|
|
# An exception reducing the fee
|
|
exceptions = {('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}}
|
|
|
|
# A matching payment
|
|
transactions = [{
|
|
'date': '2026-01-05',
|
|
'amount': 400,
|
|
'person': 'Alice',
|
|
'purpose': '2026-01',
|
|
'inferred_amount': 400,
|
|
'sender': 'Alice Sender',
|
|
'message': 'fee'
|
|
}]
|
|
```
|
|
|
|
## What's Not Tested
|
|
|
|
| Area | Status | Notes |
|
|
|------|--------|-------|
|
|
| Name matching logic | ❌ Not tested | `match_members()`, `_build_name_variants()` |
|
|
| Czech month parsing | ❌ Not tested | `parse_month_references()` |
|
|
| Fio bank data fetching | ❌ Not tested | Both API and HTML scraping |
|
|
| Sync deduplication | ❌ Not tested | `generate_sync_id()` |
|
|
| QR code generation | ❌ Not tested | `/qr` route |
|
|
| Payment inference | ❌ Not tested | `infer_payments.py` logic |
|
|
| Multi-person payment splitting | ❌ Not tested | Even split across members/months |
|
|
| Edge cases | ❌ Not tested | Empty sheets, malformed dates, etc. |
|
|
|
|
## Writing New Tests
|
|
|
|
### Adding a Flask route test
|
|
|
|
```python
|
|
# In tests/test_app.py
|
|
|
|
@patch('app.some_function')
|
|
def test_new_route(self, mock_fn):
|
|
mock_fn.return_value = expected_data
|
|
response = self.client.get('/new-route')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(b'expected content', response.data)
|
|
```
|
|
|
|
### Adding a reconciliation logic test
|
|
|
|
```python
|
|
# In tests/test_reconcile_exceptions.py (or a new test file)
|
|
|
|
def test_multi_month_payment(self):
|
|
members = [('Bob', 'A', {
|
|
'2026-01': (750, 3),
|
|
'2026-02': (750, 4)
|
|
})]
|
|
transactions = [{
|
|
'date': '2026-02-01',
|
|
'amount': 1500,
|
|
'person': 'Bob',
|
|
'purpose': '2026-01, 2026-02',
|
|
'inferred_amount': 1500,
|
|
'sender': 'Bob',
|
|
'message': 'leden+unor'
|
|
}]
|
|
result = reconcile(members, ['2026-01', '2026-02'], transactions)
|
|
bob = result['members']['Bob']
|
|
self.assertEqual(bob['months']['2026-01']['paid'], 750)
|
|
self.assertEqual(bob['months']['2026-02']['paid'], 750)
|
|
self.assertEqual(bob['total_balance'], 0)
|
|
```
|
|
|
|
---
|
|
|
|
*Testing documentation generated from comprehensive code analysis on 2026-03-03.*
|