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).
中文简介正在准备中。当前显示原文说明。
查看安装方法安装前可以查看交给 AI 代理的指令内容。
| 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).
中文简介正在准备中。当前显示原文说明。