You have shared code running on Android and iOS. But how do you know it works correctly on both platforms? You test it.

KMP has built-in support for testing. Tests written in commonTest run on every platform automatically. Write once, verify everywhere.

Test Source Sets

KMP projects have test source sets that mirror the main source sets:

shared/src/
├── commonMain/    → shared code
├── commonTest/    → tests for shared code (runs on all platforms)
├── androidMain/   → Android-specific code
├── androidTest/   → Android-specific tests
├── iosMain/       → iOS-specific code
└── iosTest/       → iOS-specific tests

Tests in commonTest are the most valuable — they verify your shared logic on every target platform with a single test file.

Setup

Dependencies

# gradle/libs.versions.toml
[versions]
kotlin = "2.1.0"
ktor = "3.0.0"
kotlinx-coroutines = "1.9.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
// shared/build.gradle.kts
sourceSets {
    commonTest.dependencies {
        implementation(libs.kotlin.test)
        implementation(libs.ktor.client.mock)
        implementation(libs.kotlinx.coroutines.test)
    }
}

kotlin-test is the multiplatform testing library. It provides @Test, assertEquals, assertTrue, and other assertions that work on all platforms.

Step 1: Your First Test

Let us test a simple use case:

// shared/src/commonMain/kotlin/domain/usecase/ValidateEmailUseCase.kt

class ValidateEmailUseCase {
    operator fun invoke(email: String): Boolean {
        if (email.isBlank()) return false
        if (!email.contains("@")) return false
        if (!email.contains(".")) return false
        if (email.length < 5) return false
        return true
    }
}
// shared/src/commonTest/kotlin/domain/usecase/ValidateEmailUseCaseTest.kt

import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class ValidateEmailUseCaseTest {

    private val validateEmail = ValidateEmailUseCase()

    @Test
    fun validEmailReturnsTrue() {
        assertTrue(validateEmail("sam@example.com"))
        assertTrue(validateEmail("alex.jordan@company.org"))
    }

    @Test
    fun blankEmailReturnsFalse() {
        assertFalse(validateEmail(""))
        assertFalse(validateEmail("   "))
    }

    @Test
    fun emailWithoutAtReturnsFalse() {
        assertFalse(validateEmail("samexample.com"))
    }

    @Test
    fun emailWithoutDotReturnsFalse() {
        assertFalse(validateEmail("sam@example"))
    }

    @Test
    fun shortEmailReturnsFalse() {
        assertFalse(validateEmail("a@b"))
    }
}

Run tests with:

./gradlew :shared:allTests

This runs the test on every platform target — Android JVM, iOS simulator, and any other targets you have configured.

Step 2: Testing Coroutines

Most shared code uses coroutines. Use runTest from kotlinx-coroutines-test:

// shared/src/commonTest/kotlin/domain/usecase/GetNotesUseCaseTest.kt

import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class GetNotesUseCaseTest {

    @Test
    fun returnsSortedNotesByDate() = runTest {
        // Arrange
        val fakeRepository = FakeNoteRepository()
        fakeRepository.addNote(Note(id = "1", title = "Old", content = "", createdAt = 100, updatedAt = 100))
        fakeRepository.addNote(Note(id = "2", title = "New", content = "", createdAt = 200, updatedAt = 200))

        val useCase = GetNotesUseCase(fakeRepository)

        // Act
        val result = useCase()

        // Assert
        assertEquals("New", result.first().title)
        assertEquals("Old", result.last().title)
    }
}

runTest runs a coroutine in a test environment with a virtual clock — no real waiting, instant execution.

Step 3: Fakes Over Mocks

In KMP, fakes are preferred over mocking libraries. Why? Most mocking libraries (like Mockito) are Android/JVM-only. Fakes work on all platforms.

// shared/src/commonTest/kotlin/testdoubles/FakeNoteRepository.kt

class FakeNoteRepository : NoteRepository {

    private val notes = mutableListOf<Note>()

    fun addNote(note: Note) {
        notes.add(note)
    }

    override suspend fun getNotes(): List<Note> = notes.toList()

    override suspend fun getNoteById(id: String): Note? {
        return notes.find { it.id == id }
    }

    override suspend fun createNote(title: String, content: String): Note {
        val note = Note(
            id = "generated-${notes.size + 1}",
            title = title,
            content = content,
            createdAt = System.currentTimeMillis(),
            updatedAt = System.currentTimeMillis()
        )
        notes.add(note)
        return note
    }

    override suspend fun updateNote(note: Note): Note {
        val index = notes.indexOfFirst { it.id == note.id }
        if (index != -1) {
            notes[index] = note
        }
        return note
    }

    override suspend fun deleteNote(id: String) {
        notes.removeAll { it.id == id }
    }

    override fun observeNotes(): Flow<List<Note>> {
        return flowOf(notes.toList())
    }
}

This fake implements the repository interface with an in-memory list. Simple, predictable, works everywhere.

Step 4: Testing Ktor with MockEngine

Ktor provides MockEngine for testing HTTP calls without a real server:

// shared/src/commonTest/kotlin/data/remote/NoteApiServiceTest.kt

import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals

class NoteApiServiceTest {

    private fun createMockClient(responseBody: String, statusCode: HttpStatusCode = HttpStatusCode.OK): HttpClient {
        val mockEngine = MockEngine { _ ->
            respond(
                content = responseBody,
                status = statusCode,
                headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
            )
        }
        return HttpClient(mockEngine) {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
    }

    @Test
    fun getNotesReturnsListOfNotes() = runTest {
        val json = """
            [
                {
                    "id": "1",
                    "title": "Test Note",
                    "content": "Hello",
                    "created_at": 1000,
                    "updated_at": 2000,
                    "is_favorite": false
                }
            ]
        """.trimIndent()

        val client = createMockClient(json)
        val api = NoteApiService(client)

        val notes = api.getNotes()

        assertEquals(1, notes.size)
        assertEquals("Test Note", notes.first().title)
        assertEquals("Hello", notes.first().content)
    }

    @Test
    fun getNotesHandlesEmptyList() = runTest {
        val client = createMockClient("[]")
        val api = NoteApiService(client)

        val notes = api.getNotes()

        assertEquals(0, notes.size)
    }
}

Testing Specific Requests

You can also verify that the correct HTTP method and URL are used:

@Test
fun createNoteSendsPostRequest() = runTest {
    var requestMethod: HttpMethod? = null
    var requestUrl: String? = null

    val mockEngine = MockEngine { request ->
        requestMethod = request.method
        requestUrl = request.url.toString()
        respond(
            content = """{"id":"1","title":"New","content":"Body","created_at":1000,"updated_at":1000,"is_favorite":false}""",
            headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
        )
    }

    val client = HttpClient(mockEngine) {
        install(ContentNegotiation) { json() }
    }
    val api = NoteApiService(client)

    api.createNote("New", "Body")

    assertEquals(HttpMethod.Post, requestMethod)
    assertTrue(requestUrl?.contains("/notes") == true)
}

Testing Error Responses

@Test
fun getNotesThrowsOnServerError() = runTest {
    val client = createMockClient(
        responseBody = """{"error": "Internal Server Error"}""",
        statusCode = HttpStatusCode.InternalServerError
    )
    val api = NoteApiService(client)

    assertFailsWith<Exception> {
        api.getNotes()
    }
}

Step 5: Testing SQLDelight

SQLDelight queries can be tested using an in-memory database:

// shared/src/commonTest/kotlin/data/local/NoteQueriesTest.kt

import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class NoteQueriesTest {

    private lateinit var database: AppDatabase
    private lateinit var queries: NoteQueries

    @BeforeTest
    fun setup() {
        val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
        AppDatabase.Schema.create(driver)
        database = AppDatabase(driver)
        queries = database.noteQueries
    }

    @Test
    fun insertAndSelectNote() {
        queries.insertNote(
            id = "1",
            title = "Test",
            content = "Content",
            createdAt = 1000,
            updatedAt = 2000,
            isFavorite = false
        )

        val notes = queries.getAllNotes().executeAsList()

        assertEquals(1, notes.size)
        assertEquals("Test", notes.first().title)
    }

    @Test
    fun deleteNoteRemovesIt() {
        queries.insertNote("1", "Test", "Content", 1000, 2000, false)
        queries.deleteNote("1")

        val note = queries.getNoteById("1").executeAsOneOrNull()
        assertNull(note)
    }

    @Test
    fun updateNoteChangesValues() {
        queries.insertNote("1", "Old Title", "Old Content", 1000, 2000, false)
        queries.insertNote("1", "New Title", "New Content", 1000, 3000, true)

        val note = queries.getNoteById("1").executeAsOneOrNull()
        assertEquals("New Title", note?.title)
        assertEquals(true, note?.isFavorite)
    }
}

Note: The JVM SQLite driver works in commonTest when running on JVM. For iOS-specific database tests, use iosTest with the native driver.

Step 6: Testing ViewModels

Test ViewModels by providing fakes and verifying state changes:

// shared/src/commonTest/kotlin/viewmodel/NoteListViewModelTest.kt

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

@OptIn(ExperimentalCoroutinesApi::class)
class NoteListViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private lateinit var fakeRepository: FakeNoteRepository
    private lateinit var viewModel: NoteListViewModel

    @BeforeTest
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        fakeRepository = FakeNoteRepository()
    }

    @AfterTest
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun initialStateIsLoading() = runTest {
        fakeRepository.addNote(
            Note("1", "Test", "Content", 1000, 2000)
        )
        viewModel = NoteListViewModel(fakeRepository)

        // Before advancing, state should show loading
        assertTrue(viewModel.state.value.isLoading)
    }

    @Test
    fun loadNotesUpdatesState() = runTest {
        fakeRepository.addNote(
            Note("1", "Test Note", "Content", 1000, 2000)
        )
        viewModel = NoteListViewModel(fakeRepository)

        // Advance coroutines
        testDispatcher.scheduler.advanceUntilIdle()

        val state = viewModel.state.value
        assertFalse(state.isLoading)
        assertEquals(1, state.notes.size)
        assertEquals("Test Note", state.notes.first().title)
    }

    @Test
    fun deleteNoteRemovesFromState() = runTest {
        fakeRepository.addNote(Note("1", "Note 1", "", 1000, 1000))
        fakeRepository.addNote(Note("2", "Note 2", "", 2000, 2000))
        viewModel = NoteListViewModel(fakeRepository)
        testDispatcher.scheduler.advanceUntilIdle()

        viewModel.deleteNote("1")
        testDispatcher.scheduler.advanceUntilIdle()

        assertEquals(1, viewModel.state.value.notes.size)
        assertEquals("Note 2", viewModel.state.value.notes.first().title)
    }
}

Key patterns:

  • Dispatchers.setMain(testDispatcher) — replaces the main dispatcher with a test one
  • testDispatcher.scheduler.advanceUntilIdle() — runs all pending coroutines
  • Use fakes for all dependencies
  • Assert on the ViewModel’s state

Running Tests

# Run all tests on all platforms
./gradlew :shared:allTests

# Run only common tests on JVM
./gradlew :shared:jvmTest

# Run only Android tests
./gradlew :shared:testDebugUnitTest

# Run only iOS tests (requires Mac)
./gradlew :shared:iosSimulatorArm64Test

Test Organization Tips

Name Tests Clearly

// BAD — unclear what is being tested
@Test
fun test1() { }

// GOOD — describes the behavior
@Test
fun emptyTitleReturnsValidationError() { }

@Test
fun loadNotesShowsLoadingThenResults() { }

Use Arrange-Act-Assert

@Test
fun createNoteWithBlankTitleFails() = runTest {
    // Arrange
    val useCase = CreateNoteUseCase(FakeNoteRepository())

    // Act
    val result = useCase("", "Some content")

    // Assert
    assertTrue(result.isFailure)
}

Put tests for the same class in the same file, matching the source structure:

commonTest/kotlin/
├── domain/
│   └── usecase/
│       ├── GetNotesUseCaseTest.kt
│       ├── CreateNoteUseCaseTest.kt
│       └── ValidateEmailUseCaseTest.kt
├── data/
│   ├── remote/
│   │   └── NoteApiServiceTest.kt
│   └── local/
│       └── NoteQueriesTest.kt
├── viewmodel/
│   └── NoteListViewModelTest.kt
└── testdoubles/
    └── FakeNoteRepository.kt

Common Assertions

AssertionUse For
assertEquals(expected, actual)Values match
assertNotEquals(a, b)Values differ
assertTrue(condition)Condition is true
assertFalse(condition)Condition is false
assertNull(value)Value is null
assertNotNull(value)Value is not null
assertIs<Type>(value)Value is a specific type
assertFailsWith<Exception> { }Block throws exception

Source Code

The KMP tutorial project is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Error Handling and Logging in KMP — the Result pattern, Kermit for multiplatform logging, and handling network errors gracefully.

See you there.