Testing shared KMM code requires a strategy that validates logic once in commonMain but also verifies platform-specific behavior in androidTest and iosTest. CI must run all three suites.
Testing Layers
commonTest/
├── ArticleRepositoryTest.kt (pure logic, fakes only)
├── GetArticlesUseCaseTest.kt (use case with fake repo)
└── DateFormatterTest.kt (expect/actual)
androidTest/
├── ArticleRoomMigrationTest.kt (Room-specific)
└── BiometricBridgeTest.kt (platform-specific)
iosTest/
└── KeychainStorageTest.kt (Keychain-specific)
androidInstrumentedTest/
└── ArticleRepositoryIntegrationTest.kt (SQLDelight + real DB on device)
commonTest: Pure Logic with Fakes
// commonTest/ArticleRepositoryTest.kt
class FakeArticleApi : ArticleApi {
var result: List<ArticleDto> = emptyList()
var throwError: Boolean = false
override suspend fun fetchArticles(): List<ArticleDto> {
if (throwError) throw IOException("Network error")
return result
}
}
class FakeArticleCache : ArticleCache {
private val store = mutableMapOf<String, Article>()
override fun observeAll(): Flow<List<Article>> = flowOf(store.values.toList())
override suspend fun upsert(articles: List<Article>) = articles.forEach { store[it.id] = it }
override suspend fun clear() = store.clear()
}
class ArticleRepositoryTest {
private val fakeApi = FakeArticleApi()
private val fakeCache = FakeArticleCache()
private val repository = ArticleRepositoryImpl(fakeApi, fakeCache)
@Test
fun `refreshArticles stores articles in cache`() = runTest {
fakeApi.result = listOf(ArticleDto("1", "Title", "Body", 1000L, "Alice"))
repository.refreshArticles()
val cached = repository.observeArticles().first()
assertEquals(1, cached.size)
assertEquals("Title", cached.first().title)
}
@Test
fun `refreshArticles rethrows network error`() = runTest {
fakeApi.throwError = true
assertFailsWith<IOException> {
repository.refreshArticles()
}
}
}
commonTest: Turbine for Flow Testing
class GetArticlesUseCaseTest {
@Test
fun `emits sorted articles newest first`() = runTest {
val oldArticle = Article("1", "Old", "", publishedAt = 1000L, authorName = "")
val newArticle = Article("2", "New", "", publishedAt = 2000L, authorName = "")
val fakeRepo = FakeArticleRepository(listOf(oldArticle, newArticle))
val useCase = GetArticlesUseCase(fakeRepo)
useCase().test {
val items = awaitItem()
assertEquals("New", items.first().title)
cancelAndIgnoreRemainingEvents()
}
}
}
androidTest: Room-Specific Tests
@RunWith(AndroidJUnit4::class)
class ArticleDaoTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var db: AppDatabase
private lateinit var dao: ArticleDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().context,
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.articleDao()
}
@After
fun tearDown() = db.close()
@Test
fun insertAndObserve() = runTest {
dao.upsert(ArticleEntity(id = "1", title = "Test", /* ... */))
val articles = dao.observeAll().first()
assertEquals(1, articles.size)
}
}
CI Pipeline (GitHub Actions)
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with: { java-version: '17', distribution: 'temurin' }
- name: Gradle cache
uses: gradle/gradle-build-action@v2
- name: Unit tests (common + Android)
run: ./gradlew testDebugUnitTest
- name: Instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
script: ./gradlew connectedDebugAndroidTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: "**/build/reports/tests/"
test-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with: { java-version: '17', distribution: 'temurin' }
- name: KMM iOS tests (Kotlin side)
run: ./gradlew iosSimulatorArm64Test
- name: XCTest (Swift/UI layer)
run: xcodebuild test
-scheme MyApp
-destination "platform=iOS Simulator,name=iPhone 15"
-resultBundlePath build/TestResults.xcresult
Key Takeaways
| Layer | Where to test | Framework |
|---|---|---|
| Shared business logic | commonTest | runTest + Turbine |
| Platform-specific impl | androidTest / iosTest | JUnit / XCTest |
| Integration (real DB) | androidInstrumentedTest | Espresso + Room |
| CI: Android | GitHub Actions + emulator | Gradle + connectedAndroidTest |
| CI: iOS | macOS runner | xcodebuild test |
| Test isolation | Fakes not mocks in shared tests | Avoids platform leakage |