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
This commit is contained in:
204
android/COVERAGE_PLAN.md
Normal file
204
android/COVERAGE_PLAN.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# 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`](./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`:
|
||||||
|
|
||||||
|
```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; ~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:
|
||||||
|
|
||||||
|
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 (~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:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 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):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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.
|
||||||
@@ -1002,6 +1002,10 @@ informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainabil
|
|||||||
gate failure is **coverage = 0% on new code**, which is a *reporting* gap, not a testing gap — the
|
gate failure is **coverage = 0% on new code**, which is a *reporting* gap, not a testing gap — the
|
||||||
25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.
|
25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.
|
||||||
|
|
||||||
|
> Once the wiring below landed (`Android-app#26`), real coverage measured **16.4%** on new code —
|
||||||
|
> still under the 50% gate. The plan to raise it (exclude non-unit-testable framework/UI code + test
|
||||||
|
> ViewModels/DTOs/repositories) lives in [`COVERAGE_PLAN.md`](./COVERAGE_PLAN.md).
|
||||||
|
|
||||||
**Coverage wiring (the fix):**
|
**Coverage wiring (the fix):**
|
||||||
- Apply the `jacoco` plugin in `app/build.gradle.kts` + a `jacocoTestReport` task fed by
|
- Apply the `jacoco` plugin in `app/build.gradle.kts` + a `jacocoTestReport` task fed by
|
||||||
`testDebugUnitTest`, emitting XML. Exclude generated/DI/Compose scaffolding
|
`testDebugUnitTest`, emitting XML. Exclude generated/DI/Compose scaffolding
|
||||||
|
|||||||
Reference in New Issue
Block a user