Skip to content

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.amountMillis is minor units (25.402540).
  • expected: null means the text must not produce a candidate.
  • Keep the ≥90% HIGH-confidence ratio — add PARTIAL cases sparingly.
  • misc.json holds cross-package/edge cases.

Writing a new unit test

  1. Follow the pure-JVM rule: no android.* imports in the test target.
  2. For anything touching settings/security, use an in-memory SettingsStore fake + fake LockClock (pattern in AppLockManagerTest).
  3. For classification/override tests, prefer in-memory DAO fakes over Room.
  4. Name tests behaviorally (fun masksLongDigitRuns(), not fun 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.