Files
docs/android/COVERAGE_PLAN.md
wtclaude 43d9782d2e docs(android): add test coverage plan to reach the 50% gate
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
2026-07-22 14:27:39 -05:00

9.9 KiB
Raw Permalink Blame History

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.kt holds 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; ~34 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:

  1. LoginViewModel (116), AccountViewModel (104), CharactersViewModel (77), AdminContentViewModel (74), NotificationsViewModel (66), TrustedDevicesViewModel (63), ShardViewModel (59)
  2. HousesViewModel (51), GovernorsViewModel (47), AdminDashboardViewModel (43), AdminSupportViewModel (41), ChampsViewModel (40), AdminModerationViewModel (40), GuildsViewModel (39), RecoveryCodesViewModel (38), ConnectViewModel (37), ContactViewModel (36), VendorsViewModel (32), AppViewModel (29)
  3. 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 (~12 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.value after advanceUntilIdle(); or collect the StateFlow in a background launch when you need to see intermediate (loading) states.
  • Optional deps (decide once): mockk would cut fake-writing for wide interfaces, and turbine simplifies 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 jacocoTestReport before the scan (sonarqube.yml), so new tests count automatically on merge to main. 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.exclusions removes 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 a RobolectricTestRunner suite rather than instrumented tests, to keep it in the fast JVM test source 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.