Testing
Running
cd android-app
./gradlew testDebugUnitTest # pure-JVM unit tests — fast, no device
./gradlew connectedDebugAndroidTest # instrumented (Compose UI) tests — needs device/emulator
Unit tests are pure JVM (no Robolectric, no Android deps) — that's a
deliberate constraint: the parser, money, codecs, and lock logic avoid
Android imports so they test in milliseconds. ViewModel aggregation logic is
factored into pure top-level functions (aggregateHome, computeReportTotals)
so it is unit-testable without a Context.
Inventory
Unit tests (app/src/test/)
| File | Covers |
|---|---|
parser/ParseGoldenTest.kt |
Every fixture parses to exact expected values; ≥90% HIGH-confidence gate |
parser/AmountParserTest.kt |
Decimal separators, thousands, symbols, signs, JPY 0 digits |
parser/DirectionStatusTest.kt |
DirectionDetector / StatusDetector flags and precedence |
parser/EmbeddedJsonParserTest.kt |
Balanced-JSON extraction, fields, malformed input |
parser/NotificationKeyTest.kt |
Key format, normalization, whitespace collapse |
listener/NotificationExtractorTest.kt |
Text flattening, digit masking (****last4) |
listener/NotificationFilterTest.kt |
Promo/OTP exclusion, transaction signal, masked detection |
common/MoneyFormatterTest.kt |
Grouping, symbols, minor digits, negatives |
common/MoneyTextReformatTest.kt |
Currency-switch reformatting (digit counts, unparseable passthrough) |
common/ExportCodecTest.kt |
CSV escaping, JSON round-trip |
ui/transactions/TransactionDraftExportRowTest.kt |
Draft → export row mapping (names, amount fallback, rawJson) |
common/AppLockManagerTest.kt |
Salted hash, lockout, timeout, idle-activity reset (recordActivity), onStop relock, disable, biometric flags (in-memory SettingsStore + fake LockClock) |
common/UnmatchedJsonCodecTest.kt |
Clipboard JSON round-trip |
common/UnmatchedExportCodecTest.kt |
Bulk developer export: gzip+base64 round-trip, escaping, empty |
ui/unmatched/UnmatchedViewModelTest.kt |
Pending grouping by package, newest-first order, export payload |
ui/lock/AppLockViewModelTest.kt |
Lock state machine (verify, lockout countdown, biometric) + auto-lock ticker (virtual time re-evaluates the idle deadline) |
common/FxTest.kt |
FX conversion: same-base no-op, SGD→MYR, JPY 0 digits, HALF_UP rounding, missing-rate/unknown → excluded |
data/repo/SettingsRepositoryTest.kt |
FX-rate map serialize/round-trip via KEY_FX_RATES, corrupt-safe |
ui/home/HomeAggregateTest.kt |
aggregateHome — spent/income/biggestSpend convert foreign to base; merchant distinct count; unconvertible rows excluded + flagged |
ui/reports/ReportAggregateTest.kt |
computeReportTotals — report total/slices/trend convert to base; 6-month trend bucketing + completed-expense filter; flagged on missing rate |
ui/reports/charts/ChartAdaptersTest.kt |
Report data → ComposeCharts models: pie/bar mapping, palette wrap-around, current-month color, tooltip/popup strings, empty/zero/single edges |
ui/transactions/TransactionsViewModelTest.kt |
computeDayTotals/formatDayNet + ViewModel state — per-day base-currency net, direction/category/search filters, day totals via settings currency |
ui/budgets/BudgetViewModelTest.kt |
Budget spent converted to base units for limit comparison; flag on unconvertible |
data/model/AttachmentTypeTest.kt |
attachmentTypeFor mime/extension mapping + AttachmentType dbValue round-trip |
data/repo/AttachmentValidationTest.kt |
isSupportedAttachment — txt/image/pdf/csv/xlsx/docx accepted; apk/html/exe/octet-stream rejected |
common/GeoTest.kt |
projectGeo pixel→lat/lng projection, formatGeo formatting |
listener/LocationCaptureTest.kt |
LocationCapture.enabled gate (true/false/null/empty) |
Instrumented tests (app/src/androidTest/)
| File | Covers |
|---|---|
ui/lock/LockScreenTest.kt |
PIN dots, unlock flow, error/lockout UI |
ui/lock/AutoLockExpiryTest.kt |
Auto-lock idle expiry: lock overlay composes over content through real AppLockManager + AppLockViewModel, then unlocks again |
ui/settings/SecuritySectionTest.kt |
PIN setup/change dialogs, biometric controls |
ui/settings/AppearanceSectionTest.kt |
Theme + dynamic color controls |
ui/components/TransactionRowTest.kt |
Row renders transaction's own currency |
ui/components/TransactionDeleteConfirmDialogTest.kt |
Delete-confirm dialog: confirm/dismiss callbacks, merchant body, blank fallback |
ui/transactions/AttachmentSectionTest.kt |
Attachment list renders file names, add/remove callbacks |
ui/transactions/ItemizationSectionTest.kt |
Item rows render, add/remove, mismatch warning, input propagation |
ui/unmatched/UnmatchedContentTest.kt |
Group headers, expand/collapse, single-copy long-press, export visibility |
ui/reports/charts/DonutChartTest.kt |
Donut: center total, legend rows, chart contentDescription, tap-selection tooltip, empty state |
ui/reports/charts/TrendChartTest.kt |
Trend columns: six month labels, per-bar contentDescription, tap tooltip with formatted amount, all-zero empty state |
data/db/Migration7To8Test.kt |
v7→v8 migration creates attachments table + index, schema matches 8.json |
data/repo/AttachmentRepositoryTest.kt |
Staging bytes to filesDir, persist/load/delete lifecycle, unsupported-type rejection (in-memory Room + SQLCipher) |
Golden fixtures (app/src/test/resources/fixtures/)
One JSON file per package: bigpay, boost, cimb, grab, mae,
maybank, misc, pbb, rhb, shopee, tng, wallet, wise.
(ParseGoldenTest enumerates the directory, so new files are picked up
automatically.)
Shape:
{
"packageName": "my.com.maybank2u",
"currency": "MYR",
"cases": [
{ "title": "…", "body": "RM 25.40 DEBIT from A/C 12345678", "expected": { "amountMillis": 2540, "merchant": null, "direction": "EXPENSE", "status": "COMPLETED", "confidence": "PARTIAL" } },
{ "title": "…", "body": "…unparseable…", "expected": null }
]
}
Rules for editing fixtures:
expected.amountMillisis minor units (25.40→2540).expected: nullmeans the text must not produce a candidate.- Keep the ≥90% HIGH-confidence ratio — add PARTIAL cases sparingly.
misc.jsonholds cross-package/edge cases.
Writing a new unit test
- Follow the pure-JVM rule: no
android.*imports in the test target. - For anything touching settings/security, use an in-memory
SettingsStorefake + fakeLockClock(pattern inAppLockManagerTest). - For classification/override tests, prefer in-memory DAO fakes over Room.
- Name tests behaviorally (
fun masksLongDigitRuns(), notfun testSanitize()).
CI
PRs run unit tests + lint + ktlint (android-tests.yml), plus an instrumented
job (connectedDebugAndroidTest) on a KVM-accelerated emulator — failures
block merge. The job enables KVM first (udev rule per the
android-emulator-runner README);
without it the emulator falls back to software emulation and never boots
within the timeout.
Related
- docs/architecture/tooling.md — build + CI details
- docs/architecture/parser.md — fixture mechanics