agentsclimarketplace

Kmp testing

Skill ShinKev/kmp-skill-library/testing_and_automation/kmp-testing

Comprehensive KMP testing strategy. Covers commonTest with kotlin.test + Turbine for shared logic, androidTest with Compose test rules and Roborazzi screenshot tests, and iOS snapshot testing guidance.From its SKILL.md

Install
npx -y skills add ShinKev/kmp-skill-library --skill kmp-testing

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

8.2 KB, ~1.9k tokens by cl100k_base, as published. Nobody here has run it

KMP Testing Strategies

This skill provides expert guidance on testing KMP + CMP applications across three tiers.

Testing Pyramid

          ┌─────────────────────┐
          │  UI / Screenshot    │  androidTest + iosApp Xcode tests
          ├─────────────────────┤
          │  Integration Tests  │  commonTest (SQLDelight queries, Ktor MockEngine)
          ├─────────────────────┤
          │    Unit Tests       │  commonTest (ViewModels, UseCases, Repos)
          └─────────────────────┘

Where Tests Live

Source SetWhat Goes Here
commonTestViewModel, UseCase, Repository, Flow tests — the majority of your tests
androidTest (or androidUnitTest)Compose UI tests, Koin integration tests, Roborazzi screenshot tests
iosTestKotlin tests for iOS actual implementations (runs on simulator)
iosApp/iosAppTests (Xcode)Swift/XCTest tests for the iOS app shell and Swift-side code

Tier 1: commonTest (kotlin.test + Turbine)

Dependencies (libs.versions.toml)

[libraries]
kotlin-test              = { module = "org.jetbrains.kotlin:kotlin-test" }
kotlinx-coroutines-test  = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
turbine                  = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
// shared/build.gradle.kts
commonTest.dependencies {
    implementation(libs.kotlin.test)
    implementation(libs.kotlinx.coroutines.test)
    implementation(libs.turbine)
}

ViewModel Unit Test

// commonTest
class NewsViewModelTest {

    private val fakeRepository = FakeNewsRepository()
    private val viewModel = NewsViewModel(fakeRepository)

    @Test
    fun `initial state is Loading`() {
        assertEquals(NewsUiState.Loading, viewModel.uiState.value)
    }

    @Test
    fun `loading news emits Success`() = runTest {
        viewModel.uiState.test {
            assertEquals(NewsUiState.Loading, awaitItem())
            viewModel.load()
            assertEquals(NewsUiState.Success(fakeNews), awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `network error emits Error state`() = runTest {
        fakeRepository.shouldFail = true

        viewModel.uiState.test {
            skipItems(1) // Loading
            viewModel.load()
            val error = awaitItem()
            assertIs<NewsUiState.Error>(error)
            cancelAndIgnoreRemainingEvents()
        }
    }
}

Repository Flow Test

// commonTest
class NewsRepositoryTest {

    private val fakeDb = FakeNewsDatabase()
    private val fakeApi = FakeNewsApi()
    private val repository = OfflineFirstNewsRepository(fakeDb, fakeApi)

    @Test
    fun `newsStream emits from local source`() = runTest {
        fakeDb.emit(listOf(testNews))

        repository.newsStream.test {
            assertEquals(listOf(testNews), awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `refresh writes API data to DB`() = runTest {
        fakeApi.news = listOf(remoteNews)
        repository.refresh()
        assertEquals(listOf(remoteNews.toDomain()), fakeDb.stored)
    }
}

Testing Dispatcher Injection

// commonTest
class SearchViewModelTest {

    private val testDispatcher = StandardTestDispatcher()

    @Test
    fun `search debounces input`() = runTest(testDispatcher) {
        val viewModel = SearchViewModel(FakeSearchRepo(), testDispatcher)

        viewModel.onSearchQuery("ko")
        advanceTimeBy(200) // Less than debounce threshold
        assertEquals(0, viewModel.results.value.size)

        advanceTimeBy(400) // Past debounce threshold
        advanceUntilIdle()
        assertTrue(viewModel.results.value.isNotEmpty())
    }
}

Tier 2: androidTest (Compose + Roborazzi)

Dependencies

[libraries]
junit4                   = { module = "junit:junit", version = "4.13.2" }
kotlinx-coroutines-test  = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
androidx-test-ext-junit  = { group = "androidx.test.ext", name = "junit", version = "1.1.5" }
compose-ui-test          = { group = "androidx.compose.ui", name = "ui-test-junit4" }
roborazzi                = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }

Compose UI Test

@RunWith(AndroidJUnit4::class)
class NewsScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun newsScreen_showsLoadingIndicator() {
        composeTestRule.setContent {
            NewsScreen(uiState = NewsUiState.Loading, onRefresh = {})
        }
        composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
    }

    @Test
    fun newsScreen_showsNewsList_whenSuccess() {
        composeTestRule.setContent {
            NewsScreen(uiState = NewsUiState.Success(fakeNews), onRefresh = {})
        }
        composeTestRule.onNodeWithText(fakeNews.first().title).assertIsDisplayed()
    }
}

Screenshot Testing with Roborazzi

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [33], qualifiers = RobolectricDeviceQualifiers.Pixel5)
class NewsScreenScreenshotTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun capture_loading_state() {
        composeTestRule.setContent {
            AppTheme { NewsScreen(uiState = NewsUiState.Loading, onRefresh = {}) }
        }
        composeTestRule.onRoot().captureRoboImage()
    }
}

Run commands:

./gradlew recordRoborazziDebug   # Record golden images
./gradlew verifyRoborazziDebug   # Compare against golden images

Tier 3: iOS Testing

There are two separate test surfaces on iOS — do not confuse them:

3a. KMP iosTest source set (Kotlin)

Lives in shared/src/iosTest/kotlin/. Tests Kotlin actual implementations that live in iosMain. Runs on the iOS simulator via Gradle.

// shared/src/iosTest/kotlin/CryptoImplTest.kt
class IosCryptoImplTest {
    @Test
    fun encryptDecryptRoundTrip() {
        val crypto = IosCryptoImpl()
        val data = "hello".encodeToByteArray()
        val key = ByteArray(32)
        val encrypted = crypto.encrypt(data, key)
        val decrypted = crypto.decrypt(encrypted, key)
        assertContentEquals(data, decrypted)
    }
}

Run with: ./gradlew :shared:iosSimulatorArm64Test

3b. Xcode test target (Swift)

Lives in the Xcode project at iosApp/iosAppTests/. Tests Swift app-shell code and integrates with the Kotlin/Native framework from the Swift side.

// iosApp/iosAppTests/ProfileViewTests.swift
import XCTest
@testable import iosApp
import shared

class ProfileViewTests: XCTestCase {
    func testViewModelFromSwift() {
        let vm = ProfileViewModel(savedStateHandle: ..., userRepository: FakeUserRepository())
        // Exercise the KMP VM from Swift
    }
}

For Swift snapshot tests, use swift-snapshot-testing.

Run with: xcodebuild test -project iosApp/iosApp.xcodeproj -scheme iosApp -destination 'platform=iOS Simulator,name=iPhone 15'

Default strategy: test shared logic in commonTest (it's faster, runs on JVM, no simulator needed). Reserve iosTest for actual implementations, and the Xcode test target for Swift app-shell code.


Running Tests

# All shared tests (runs on JVM)
./gradlew :shared:allTests

# Android unit tests only
./gradlew :androidApp:test

# Android instrumented tests
./gradlew :androidApp:connectedAndroidTest

# iOS tests (requires Xcode / xcodebuild)
xcodebuild test -project iosApp/iosApp.xcodeproj -scheme iosApp -destination 'platform=iOS Simulator,name=iPhone 15'

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,764. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.