androidengineers.Book a session

Cross-Platform Considerations

Testing & CI for Multiplatform

article20 minHard

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

LayerWhere to testFramework
Shared business logiccommonTestrunTest + Turbine
Platform-specific implandroidTest / iosTestJUnit / XCTest
Integration (real DB)androidInstrumentedTestEspresso + Room
CI: AndroidGitHub Actions + emulatorGradle + connectedAndroidTest
CI: iOSmacOS runnerxcodebuild test
Test isolationFakes not mocks in shared testsAvoids platform leakage

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Testing & CI for Multiplatform | Android System Design | Android Engineers