本文へ移動
cccskills
無料GitHub で公開

kotlin-test-patterns

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).

インストール方法を見る

含まれるファイル(1)

  • SKILL.md11.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Kotlin Test Patterns -- RIPDPI

1. Test Module Layout

Source setRunnerPurpose
app/src/test/Robolectric + JUnit 4ViewModel, Compose UI, screenshot, pure logic
app/src/androidTest/AndroidJUnitRunner + HiltInstrumented integration, E2E service lifecycle
core/data/src/test/JUnit 4 (no Android)Data model serialization, settings validation
core/engine/src/test/JUnit 4Native bridge fakes, golden contract telemetry
core/diagnostics/src/test/RobolectricScan pipeline, contract governance, archive export
core/service/src/test/JUnit 4VPN/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.

2. MainDispatcherRule

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()
    }
}

3. ViewModel Testing Pattern

  1. Create fakes for all dependencies (see Section 6).
  2. Call createViewModel(...) or createDiagnosticsViewModel(...) factory.
  3. Launch a background collector: backgroundScope.launch { viewModel.uiState.collect {} }.
  4. Trigger actions, call advanceUntilIdle(), assert state.
  5. Cancel the collector.
@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()
}

4. Flow Testing with Turbine

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)
}

5. Compose UI Testing

ComposeTestRule (Robolectric)

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() }.

Roborazzi Screenshot Tests

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.

6. Test Doubles -- Hand-Rolled Fakes (No Mockk)

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.

Key locations

  • App layer: app/src/test/.../activities/TestDoubles.kt -- FakeAppSettingsRepository, FakeServiceStateStore, FakePermissionStatusProvider, FakeServiceController
  • Diagnostics ports: app/src/test/.../activities/DiagnosticsTestPorts.kt -- FakeDiagnosticsManager (aggregates fake bootstrapper, timeline, scan controller, detail loader, share service with MutableStateFlow properties)
  • Diagnostics stores: core/diagnostics/src/test/.../DiagnosticsServiceTestSupport.kt -- FakeDiagnosticsHistoryStores (in-memory implementation of all record-store interfaces)
  • Engine: core/engine/src/test/.../TestDoubles.kt -- FakeRipDpiProxyBindings (with CompletableDeferred blockers and telemetryJson field)
  • Service: core/service/src/test/.../ServiceControllerTestDoubles.kt -- TestVpnTunnelRuntime, TestTun2SocksBridge, TestVpnTunnelSession

Example fake pattern

class 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()
    }
}

7. Golden Contract Tests

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.

8. Hilt Instrumented Test Setup

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.

9. Diagnostics Test Infrastructure

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.

10. Common Mistakes

  1. Forgetting runTest: coroutine tests MUST use runTest { } -- without it, advanceUntilIdle() and backgroundScope are unavailable.

  2. Missing MainDispatcherRule: ViewModel tests crash with "Main dispatcher failed to initialize" without this rule.

  3. Not collecting uiState: stateIn(SharingStarted.WhileSubscribed) flows only emit while collected. Always: backgroundScope.launch { viewModel.uiState.collect {} }.

  4. Leaking scope: cancel background collectors at test end.

  5. Using Mockk: project convention is hand-rolled fakes. Do not introduce mocking frameworks. New fakes go in TestDoubles.kt or module-specific support files.

  6. Wrong Robolectric config: Compose tests require @GraphicsMode(NATIVE) + @Config(sdk = [35]).

  7. Golden failures after schema changes: run RIPDPI_BLESS_GOLDENS=1 ./gradlew :core:engine:test. Never manually edit golden JSON.

  8. Hilt isolation: call IntegrationTestOverrides.reset() in @Before and re-assign @BindValue fields.

  9. 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.

  10. 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).

日本語の概要は準備中です。原文の説明を表示しています。

po4yka/RIPDPI792026年10月11日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

po4yka/RIPDPI792026年10月11日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

po4yka/RIPDPI792026年10月11日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

po4yka/RIPDPI792026年10月11日 更新

RIPDPI Appium failure triage: flaky tests, locator/session/wait issues, screenshot and element-tree debugging. Use when an Appium test fails or is flaky.

日本語の概要は準備中です。原文の説明を表示しています。

po4yka/RIPDPI792026年10月11日 更新

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).

日本語の概要は準備中です。原文の説明を表示しています。

po4yka/RIPDPI792026年10月11日 更新

po4yka のスキルをすべて見る

このスキルの問題を報告する