Files
capod/.claude/rules/testing.md
T
darken 3651bb3d55 refactor(upgrade): Converge GPlay billing on the canonical stack
Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.

Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.

FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.

Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.

UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.

Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.

mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
2026-07-29 14:05:26 +02:00

80 lines
3.5 KiB
Markdown

---
description: Unit test conventions — JUnit 5, kotest assertions, mockk, BaseTest, and which Gradle task runs which source set
paths:
- "app/src/test/**"
- "app/src/testFoss/**"
- "app/src/testGplay/**"
- "app/build.gradle.kts"
- "buildSrc/src/main/java/Dependencies.kt"
---
# Testing
The stack here is not the Android default — check this before reaching for a familiar library.
## Libraries
- **JUnit 5** (`org.junit.jupiter.api.Test`). Gradle sets `useJUnitPlatform()`.
- **kotest** for assertions: `io.kotest.matchers.shouldBe`, `shouldBeNull`, `shouldBeInstanceOf`,
`shouldContainExactly`, `io.kotest.assertions.throwables.shouldThrow`. Use kotest for new
assertions — `MediaControlTest` still uses JUnit `Assertions.*` and is a legacy exception.
- **mockk** for mocking. Not Mockito.
- **Turbine is not a dependency.** `testhelpers.flow.FlowTest` provides a `Flow<T>.test()` helper —
use it rather than adding one.
## Base classes
Extend `testhelpers.BaseTest`, or the applicable specialized base that already extends it:
- `BaseBlePodsTest` — BLE advertisement parsing per pod model
- `BaseAapSessionTest` — AAP protocol/session tests
`BaseTest` installs a `JUnitLogger` and calls `unmockkAll()` in `@AfterAll`. Skipping it can leave
global mockk and logging state behind for later test classes.
The only exceptions are the Robolectric-backed tests (Compose UI via
`testhelpers.compose.BaseComposeRobolectricTest`, and the few DataStore-backed ones such as
`CurriculumVitaeProHistoryTest`), which use JUnit 4 `@RunWith`/`@Rule` via `junit-vintage-engine`.
Don't copy that pattern for a plain unit test.
## Source sets and Gradle tasks
Each task compiles and runs only its own flavor — running the wrong one silently skips your test.
| Test location | Task |
|---|---|
| `app/src/test/` (shared) | either; run both before pushing |
| `app/src/testFoss/` | `./gradlew testFossDebugUnitTest` |
| `app/src/testGplay/` | `./gradlew testGplayDebugUnitTest` |
CI runs both. Flavor-specific tests are for code that only exists in that flavor — billing in
`gplay`, the sponsor-based upgrade flow in `foss`.
## Helpers that already exist
- `runTest2(autoCancel, context, expectedError, testBody)` in `testhelpers/coroutine/TestExtensions.kt`
use `expectedError = SomeException::class` instead of hand-rolling a throws-assertion around `runTest`
- `FakeDataStoreValue<T>(initial)` in `testhelpers/datastore/` — a working fake with a real backing
`MutableStateFlow`; read/write it through `.value` and pass `.mock` to the code under test
## Mocking `DataStoreValue`
`DataStoreValue.value()` and `.value(T)` are **extension functions** (`DataStoreValue.kt:54,56`), not
members, so MockK cannot stub them. They delegate to `flow.first()` and `update { }` — stub those:
```kotlin
every { someSetting.flow } returns flowOf(value) // covers .value() reads
coVerify { someSetting.update(any()) } // verifies .value(x) writes
```
`UpgradeRepoGplayTest` uses this shape. Prefer `FakeDataStoreValue` when you need reads and writes to
actually round-trip.
## Reading ViewModel state
`ViewModel2.asLiveState()` is `stateIn(..., initialValue = null).filterNotNull()` with
`SharingStarted.WhileSubscribed(5_000)` — so `vm.state` is a `Flow`, not a `StateFlow`, and has no
`.value` to read. Collect it: `vm.state.first()` is the established pattern across the existing
ViewModel tests. Because the upstream only runs while subscribed, a test that never collects sees
nothing happen at all.