Post-#26 the JaCoCo→Sonar wiring is live and coverage measures 16.4% on new code — under the 50% gate. Add COVERAGE_PLAN.md: a bucketed analysis of the gap (ViewModels 0.1%/1153 lines is the dominant lever; DTOs, repositories, core utils next) and a phased plan — Phase 0 broadens coverage exclusions to drop non-unit-testable UI/framework code, Phases 1–4 test DTOs, ViewModels, repositories, and core. Includes a MainDispatcherRule harness + VM test pattern and per-phase coverage projections (Phase 2 clears the gate at ~65%). Cross-linked from PLAN.md §12.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
9.9 KiB
Android App — Test Coverage Plan
Goal: clear the SonarQube coverage quality gate (new_coverage ≥ 50%) for
Runic-Gateway-Android-app, and leave a durable unit-test culture behind it. Companion to
PLAN.md §12.1 (the JaCoCo wiring that made coverage measurable).
1. Current state (2026-07-22, post Android-app#26)
The JaCoCo→Sonar wiring is live on main, so coverage is now real — and the gate is failing:
| Metric | Value |
|---|---|
| Quality gate | ERROR (one condition) |
new_coverage |
16.4% (threshold ≥ 50%) |
overall coverage |
16.3% |
| lines to cover | 3,188 |
| covered | 542 |
Everything else on the gate is green (reliability/security/maintainability A, duplication 0%).
Because this is the first measured version, Sonar's "new code" window is essentially the whole
codebase, so new_coverage ≈ overall coverage — to pass we need to roughly triple covered
lines, from 542 to ~1,600.
Where the uncovered lines are
Bucketed from Sonar's per-file uncovered_lines (exclusions from #26 already applied, so *Screen.kt
is absent):
| Bucket | Files | Lines to cover | Covered % | Verdict |
|---|---|---|---|---|
| ViewModels | 28 | 1,153 | 0.1% | Test — the dominant lever; no ViewModel has any test |
| DTOs | 12 | 648 | 26.7% | Test — trivial (serialization); a pattern already exists |
| Repositories | 11 | 274 | 11.3% | Test — fake the API interface |
| core/* (logic) | 21 | 365 | 56.2% | Test — top up the partially-covered ones |
UI composables (*Components.kt, BlockRenderer, …) |
7 | 255 | 3.9% | Exclude — not JVM-unit-testable, and *Screen.kt's exclusion missed them |
| core/push (Android services) | 4 | ~191 | ~0% | Exclude (or Robolectric later) — foreground service / notifications |
core/auth Encrypted* stores |
3 | 76 | 0% | Exclude — Android Keystore / EncryptedSharedPreferences |
framework glue (di/, RunicGatewayApp, LocalAssetResolver) |
3 | ~17 | 0% | Exclude |
Two levers, applied together: (a) stop counting code a JVM unit test physically cannot execute, and (b) actually test the logic — ViewModels, DTOs, repositories, core utilities.
2. Strategy & projected math
Numbers below are line-coverage projections against Sonar's lines_to_cover. They are estimates, but
grounded in the current per-bucket totals.
Phase 0 — Broaden coverage exclusions (no tests; ~½ day)
Move non-unit-testable code out of the coverage denominator (it stays in analysis — bugs and
smells are still reported). Extend sonar.coverage.exclusions in sonar-project.properties:
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/**/*Components.kt,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/shard/FrameFields.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
Verify each glob targets composable-only / framework-only files before committing (e.g. confirm
FrameFields.ktholds no testable logic). Keep the pure-logic push files in coverage (PushPreferences,PushTickle,NtfyTopic,PushStreams) — they already have tests.
Effect: denominator ~3,188 → ~2,650; covered ~542 → ~532. Coverage ≈ 20%. (Removing ~540 lines that were ~2% covered.)
Phase 1 — DTO serialization tests (highest ROI; ~1 day) → ~34%
DTOs are @Serializable data classes; test them with kotlinx-serialization round-trips against
representative backend JSON. The pattern already exists (AccountDtoTest, AuthDtoTest,
NotificationsDtoTest, PlayerShardDtoTest, ShardDtoTest). Add/extend:
- New:
AdminDto(111 uncov — biggest single file),WikiDto(51),PublicDto(32),PageDto(16),PostDto(12),ContactDto(11),SsoDto,PageDto. - Extend to ~85%:
PlayerShardDto(30.8%),ShardDto(35.6%),AccountDto(50.7%),AuthDto(47.4%).
Target DTOs to ~85%: +~380 covered lines → covered ~912 / ~2,650 ≈ 34%.
Phase 2 — ViewModel tests (the big one; ~3–4 days) → clears the gate
28 ViewModels, ~1,153 lines, currently 0%. This is where the gate is won. Requires a small test
harness (§3). Each test drives the VM with fake collaborators and asserts UiState transitions
(loading → success/error, form validation, actions).
Priority by uncovered lines:
LoginViewModel(116),AccountViewModel(104),CharactersViewModel(77),AdminContentViewModel(74),NotificationsViewModel(66),TrustedDevicesViewModel(63),ShardViewModel(59)HousesViewModel(51),GovernorsViewModel(47),AdminDashboardViewModel(43),AdminSupportViewModel(41),ChampsViewModel(40),AdminModerationViewModel(40),GuildsViewModel(39),RecoveryCodesViewModel(38),ConnectViewModel(37),ContactViewModel(36),VendorsViewModel(32),AppViewModel(29)- The small ones (
PostViewModel,NewsViewModel,WikiViewModel,CharacterViewModel,MyHousesViewModel,WikiPageViewModel,PageViewModel,HomeViewModel,SessionViewModel)
Target ViewModels to ~70%: +~800 covered lines → covered ~1,712 / ~2,650 ≈ 65%. ✅ Gate passes.
Phases 0 + 2 alone (skipping DTOs) already reach ~50.5% — but DTOs are cheap insurance and Phase 1 lands first because it de-risks the harness work.
Phase 3 — Repository tests (~1–2 days) → margin
Repositories map API Response/exceptions to ApiResult; test with a fake *Api interface (or
OkHttp MockWebServer). Priority: AuthRepository (89), ConnectionRepository (43),
ShardRepository (27), AdminRepository (21), then the small content/wiki/player repos. Target ~70%:
+~160 lines → buffer well above 50% and resilience as the new-code window narrows.
Phase 4 — core/net + core/auth top-up (~½–1 day) → durability
Fill the partially-covered utilities: TokenAuthenticator (31), ShardStreamClient (36),
HostSelectionInterceptor (9), ApiResult (6), WebHandoff, WebsiteUrls, DeviceNameProvider,
ServerPreferences, AppConfig.
Trajectory
| After | Denominator | Covered | Coverage |
|---|---|---|---|
| Today | 3,188 | 542 | 16.3% |
| Phase 0 (exclusions) | ~2,650 | ~532 | ~20% |
| Phase 1 (DTOs) | ~2,650 | ~912 | ~34% |
| Phase 2 (ViewModels) | ~2,650 | ~1,712 | ~65% ✅ |
| Phase 3 (repos) | ~2,650 | ~1,872 | ~71% |
| Phase 4 (core) | ~2,650 | ~2,000+ | ~75%+ |
3. Test infrastructure to add
The existing suite tests pure-logic classes only; ViewModel/coroutine testing needs a little scaffold.
kotlinx-coroutines-test is already a testImplementation dependency.
MainDispatcherRule (JUnit4) — swaps Dispatchers.Main (used by viewModelScope) for a test
dispatcher:
// app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
ViewModel test pattern — hand-written fakes (matches the repo's existing no-mock convention; no new dependency):
class LoginViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private class FakeAuthRepository(var result: LoginResult) : AuthRepository { /* stub the seam */ }
@Test fun `blank credentials surface INVALID_CREDENTIALS without a network call`() = runTest {
val vm = LoginViewModel(FakeAuthRepository(LoginResult.Success), /* … */)
vm.submit()
assertEquals(LoginError.INVALID_CREDENTIALS, vm.state.value.error)
}
}
- Assert on
viewModel.state.valueafteradvanceUntilIdle(); or collect theStateFlowin a backgroundlaunchwhen you need to see intermediate (loading) states. - Optional deps (decide once):
mockkwould cut fake-writing for wide interfaces, andturbinesimplifies Flow assertions. Recommendation: stay with hand fakes to match convention; revisit only if VM tests get boilerplate-heavy.
4. Execution notes
- CI already runs
./gradlew testDebugUnitTest jacocoTestReportbefore the scan (sonarqube.yml), so new tests count automatically on merge tomain. Locally on this machine: JDK 21 needs-Pksp.incremental=false. - Sonar recomputes the gate on the post-merge scan; there's no way to fully confirm the number pre-merge. Land phases as separate PRs (0, 1, 2, …) so coverage climbs visibly and reviews stay small.
sonar.coverage.exclusionsremoves files from coverage only — analysis still flags bugs/smells in them, so excluding UI/framework code is safe.- Android-framework code deferred, not abandoned: push services and
Encrypted*stores are excluded now; if we want them covered later, add Robolectric (testImplementation) and aRobolectricTestRunnersuite rather than instrumented tests, to keep it in the fast JVMtestsource set the scan already consumes.
5. Definition of done
new_coverage ≥ 50%and the SonarQube quality gate is green.MainDispatcherRule+ a documented ViewModel test pattern exist and are reused.- Coverage exclusions list only genuinely non-unit-testable files (UI composables, Android-framework glue) — no ViewModel, repository, DTO, or pure core-logic file is excluded.