4.9 KiB
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
make test # Run all tests
make test-v # Run with verbose output
Under the hood:
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:
@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:
# 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
# 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
# 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.