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 onetestDispatcher.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)
}
Group Related Tests
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
| Assertion | Use 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:
Related Tutorials
- KMP Tutorial #6: Ktor Client — the networking code we test with MockEngine
- KMP Tutorial #7: SQLDelight — the database code we test with in-memory drivers
- KMP Tutorial #11: Clean Architecture — the layers that make testing easier
- KMP Tutorial #3: Project Structure — commonTest and platform test source sets
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.