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.
3.5 KiB
description, paths
| description | paths | |||||
|---|---|---|---|---|---|---|
| Unit test conventions — JUnit 5, kotest assertions, mockk, BaseTest, and which Gradle task runs which source set |
|
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 setsuseJUnitPlatform(). - kotest for assertions:
io.kotest.matchers.shouldBe,shouldBeNull,shouldBeInstanceOf,shouldContainExactly,io.kotest.assertions.throwables.shouldThrow. Use kotest for new assertions —MediaControlTeststill uses JUnitAssertions.*and is a legacy exception. - mockk for mocking. Not Mockito.
- Turbine is not a dependency.
testhelpers.flow.FlowTestprovides aFlow<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 modelBaseAapSessionTest— 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)intesthelpers/coroutine/TestExtensions.kt— useexpectedError = SomeException::classinstead of hand-rolling a throws-assertion aroundrunTestFakeDataStoreValue<T>(initial)intesthelpers/datastore/— a working fake with a real backingMutableStateFlow; read/write it through.valueand pass.mockto 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:
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.