RIPDPI's own Compose conventions: ViewModel pattern, Route/Screen split, DataStore->StateFlow, RipDpiThemeTokens. Use for how this app does Compose, not generic Compose API questions (see compose).
日本語の概要は準備中です。原文の説明を表示しています。
Kotlin/Compose/Hilt/coroutine test code patterns: fakes, MainDispatcherRule, Turbine, Roborazzi, golden-contract calls. Use when writing or fixing Kotlin test code. Not for the TDD workflow (use tdd).
インストールする前に、エージェントに与えられる指示の中身を確認できます。
| Source set | Runner | Purpose |
|---|---|---|
app/src/test/ | Robolectric + JUnit 4 | ViewModel, Compose UI, screenshot, pure logic |
app/src/androidTest/ | AndroidJUnitRunner + Hilt | Instrumented integration, E2E service lifecycle |
core/data/src/test/ | JUnit 4 (no Android) | Data model serialization, settings validation |
core/engine/src/test/ | JUnit 4 | Native bridge fakes, golden contract telemetry |
core/diagnostics/src/test/ | Robolectric | Scan pipeline, contract governance, archive export |
core/service/src/test/ | JUnit 4 | VPN/proxy runtime coordination, policy resolution |
Test deps bundle in gradle/libs.versions.toml: junit, kotlinx-coroutines-test,
turbine, robolectric. Additional: androidx-compose-ui-test-junit4, roborazzi, hilt-android-testing.
Located at app/src/test/kotlin/com/poyka/ripdpi/util/MainDispatcherRule.kt.
Calls Dispatchers.setMain(UnconfinedTestDispatcher()) in starting() and
Dispatchers.resetMain() in finished(). Required in every ViewModel test:
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class MainViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test
fun `initialize is explicit and idempotent`() = runTest {
val viewModel = createViewModel(initialize = false)
val collector = backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
viewModel.initialize()
runCurrent()
// assert...
collector.cancel()
}
}
createViewModel(...) or createDiagnosticsViewModel(...) factory.backgroundScope.launch { viewModel.uiState.collect {} }.advanceUntilIdle(), assert state.@Test
fun `start with granted permissions starts immediately`() = runTest {
val serviceController = FakeServiceController()
val viewModel = createViewModel(
serviceController = serviceController,
permissionStatusProvider = FakePermissionStatusProvider(
snapshot = PermissionSnapshot(
vpnConsent = PermissionStatus.Granted,
notifications = PermissionStatus.Granted,
batteryOptimization = PermissionStatus.Granted,
),
),
)
val collector = backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
viewModel.onPrimaryConnectionAction()
advanceUntilIdle()
assertEquals(listOf(Mode.VPN), serviceController.startedModes)
collector.cancel()
}
Used for one-shot effect channels and StateFlow assertion. Key APIs: test { },
awaitItem(), cancelAndIgnoreRemainingEvents().
// Effect channel testing (MainViewModelTest)
viewModel.effects.test {
viewModel.onPrimaryConnectionAction()
val effect = awaitItem() as MainEffect.RequestPermission
assertEquals(PermissionKind.Notifications, effect.kind)
cancelAndIgnoreRemainingEvents()
}
// StateFlow testing (LogsViewModelFlowTest)
vm.uiState.test {
val state = awaitItem()
assertTrue(state.logs.isEmpty())
assertEquals(LogSubsystem.entries.toSet(), state.activeSubsystems)
}
Requires @GraphicsMode(GraphicsMode.Mode.NATIVE) and @Config(sdk = [35]). The SDK is pinned below ripdpi.compileSdk/ripdpi.targetSdk in gradle.properties because the resolved Robolectric version does not yet support the current compile SDK -- see the comment in core/data/src/test/resources/robolectric.properties for the current rationale and value; do not assume it tracks targetSdk directly.
Test tags centralized in com.poyka.ripdpi.ui.testing.RipDpiTestTags.
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35])
class HomeScreenTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun backgroundGuidanceBanner() {
composeRule.setContent {
RipDpiTheme { HomeScreen(uiState = uiStateWithBothBanners(), /* ... */) }
}
composeRule.onNodeWithTag(RipDpiTestTags.HomePermissionRecommendationBanner)
.assertIsDisplayed().assertHasClickAction()
}
}
Common: assertIsDisplayed(), assertDoesNotExist(), performClick(),
performScrollTo(), performTouchInput { swipeUp() }.
Helper at app/src/test/.../ui/screenshot/RipDpiScreenshotTestSupport.kt configures
RoborazziOptions(changeThreshold = 0.01F) and inspectionMode(true).
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35])
class RipDpiScreenCatalogScreenshotTest {
@Test
fun homeExpandedScreen() {
captureRipDpiScreenshot(widthDp = 1040, heightDp = 920) {
RipDpiHomeExpandedPreviewScene()
}
}
}
Preview scenes are @Composable functions defined alongside their screens.
The project does NOT use Mockk. All test doubles are hand-rolled fakes with
MutableStateFlow fields for state and counters for call verification.
Naming convention: Fake* for port implementations, Test* for service-layer
doubles, Stub* for minimal no-ops, Recording* for call-counting wrappers.
app/src/test/.../activities/TestDoubles.kt --
FakeAppSettingsRepository, FakeServiceStateStore, FakePermissionStatusProvider,
FakeServiceControllerapp/src/test/.../activities/DiagnosticsTestPorts.kt --
FakeDiagnosticsManager (aggregates fake bootstrapper, timeline, scan controller,
detail loader, share service with MutableStateFlow properties)core/diagnostics/src/test/.../DiagnosticsServiceTestSupport.kt --
FakeDiagnosticsHistoryStores (in-memory implementation of all record-store interfaces)core/engine/src/test/.../TestDoubles.kt --
FakeRipDpiProxyBindings (with CompletableDeferred blockers and telemetryJson field)core/service/src/test/.../ServiceControllerTestDoubles.kt --
TestVpnTunnelRuntime, TestTun2SocksBridge, TestVpnTunnelSessionclass FakeAppSettingsRepository(
initialSettings: AppSettings = AppSettingsSerializer.defaultValue,
) : AppSettingsRepository {
private val state = MutableStateFlow(initialSettings)
override val settings: Flow<AppSettings> = state
override suspend fun snapshot(): AppSettings = state.value
override suspend fun update(transform: AppSettings.Builder.() -> Unit) {
state.value = state.value.toBuilder().apply(transform).build()
}
}
For the full blessing workflow, fixture locations by layer, and volatile-field scrubbing patterns, see the golden-test-management skill -- this section only shows the Kotlin GoldenContractSupport call shape.
GoldenContractSupport verifies serialization stability across Kotlin/Rust boundaries.
Copies exist in core/engine, core/diagnostics, core/service, and app/src/androidTest.
How it works: serialize to JSON, sort keys, pretty-print, compare against committed
.json file in src/test/resources/golden/. On mismatch: writes .expected, .actual,
.diff to build/golden-diffs/. Update with RIPDPI_BLESS_GOLDENS=1 ./gradlew test.
GoldenContractSupport.assertJsonGolden(
"proxy_running_first_poll.json",
json.encodeToString(NativeRuntimeSnapshot.serializer(), proxy.pollTelemetry()),
)
DiagnosticsContractGovernanceTest also verifies shared JSON fixtures decode with
current serializers, schema versions match Rust wire.rs constants, and bundled catalog
asset matches committed fixture.
Tests in app/src/androidTest/ use @HiltAndroidTest with @UninstallModules and
@BindValue to replace production modules with fakes:
@HiltAndroidTest
@UninstallModules(AppSettingsRepositoryModule::class, RipDpiProxyFactoryModule::class, /* ... */)
class ServiceLifecycleIntegrationTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@get:Rule val permRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
@BindValue @JvmField
var appSettingsRepository: AppSettingsRepository = IntegrationTestOverrides.appSettingsRepository
@Before fun setUp() {
IntegrationTestOverrides.reset()
hiltRule.inject()
}
}
IntegrationTestOverrides provides pre-configured fakes with reset() for test isolation.
Factory in core/diagnostics/src/test/.../DiagnosticsTestBuilders.kt assembles full
service graphs with sensible defaults:
internal fun createDiagnosticsServices(
context: Context,
appSettingsRepository: AppSettingsRepository,
stores: FakeDiagnosticsHistoryStores,
// ... many params with defaults
): DiagnosticsServicesBundle
Helpers: diagnosticsTestJson(), repoFixture(path), TestDiagnosticsHistoryClock,
RecordingArchiveExporter, RecordingRuntimeHistoryStartup.
Forgetting runTest: coroutine tests MUST use runTest { } -- without it,
advanceUntilIdle() and backgroundScope are unavailable.
Missing MainDispatcherRule: ViewModel tests crash with "Main dispatcher failed
to initialize" without this rule.
Not collecting uiState: stateIn(SharingStarted.WhileSubscribed) flows only
emit while collected. Always: backgroundScope.launch { viewModel.uiState.collect {} }.
Leaking scope: cancel background collectors at test end.
Using Mockk: project convention is hand-rolled fakes. Do not introduce mocking
frameworks. New fakes go in TestDoubles.kt or module-specific support files.
Wrong Robolectric config: Compose tests require @GraphicsMode(NATIVE) +
@Config(sdk = [35]).
Golden failures after schema changes: run
RIPDPI_BLESS_GOLDENS=1 ./gradlew :core:engine:test. Never manually edit golden JSON.
Hilt isolation: call IntegrationTestOverrides.reset() in @Before and
re-assign @BindValue fields.
assertIsDisplayed() failing on a below-fold LazyColumn item: Robolectric's default
test viewport (roughly 320x470px) never composes off-screen items. Use
performScrollToKey(key) to bring the item into view before asserting on it (the pattern in
AdvancedSettingsScreenCharacterizationTest.kt) -- performScrollToNode(hasText(...)) fails
on a node that has not been composed yet. Alternatively set Modifier.height(2000.dp) on the
LazyColumn under test so every item composes without scrolling. Also remember that section
headers rendered through .uppercase() (see DetectionResultCards.kt,
DetectionHistoryCommunityCards.kt) must be asserted against the uppercased string, not the
source string.
MissingBinding on an @Inject constructor with a Kotlin default parameter: Hilt/Dagger
ignores Kotlin default values on @Inject constructors, so a default does not satisfy the
graph. Add @Named("paramName") on the constructor parameter plus a matching @Provides
method in the Hilt module (see BackupRestoreViewModel.kt's @Named("appVersionName")
parameter and its provider). Tests can still pass the parameter by name as normal.
まだレビューはありません。使ってみた感想をお寄せください。
概要と使いどころ
RIPDPI's own Compose conventions: ViewModel pattern, Route/Screen split, DataStore->StateFlow, RipDpiThemeTokens. Use for how this app does Compose, not generic Compose API questions (see compose).
日本語の概要は準備中です。原文の説明を表示しています。
ADB-based RIPDPI device/emulator debugging: build/install/launch, logcat filtering, fixture port-forwarding, instrumented tests, crash/ANR triage. Use when debugging on a real device or emulator.
日本語の概要は準備中です。原文の説明を表示しています。
RIPDPI Appium launch-contract reference: start routes and permission/service/data presets. Use when a test launches to the wrong screen or a new automation route is added. General flakiness: appium-test-debug.
日本語の概要は準備中です。原文の説明を表示しています。
RIPDPI Appium test authoring: page objects, resource-id locators, assertions, wait tiers. Use when writing a new Appium test or page object, or adding coverage for a new screen.
日本語の概要は準備中です。原文の説明を表示しています。
RIPDPI Appium failure triage: flaky tests, locator/session/wait issues, screenshot and element-tree debugging. Use when an Appium test fails or is flaky.
日本語の概要は準備中です。原文の説明を表示しています。
Add or modify a GitHub Actions job. Use when editing a file under .github/workflows/. Not for running a workflow locally (local-ci-act) or release signing (release-signing).
日本語の概要は準備中です。原文の説明を表示しています。