Kotlin MultiplatformMiddleCoding

Как тестировать общий KMP-код?

Общий KMP-код тестируется через kotlin.test в commonTest. Тесты компилируются для всех таргетов. Для корутин используется kotlinx-coroutines-test с runTest, для моков — Mockative или ручные фейки.

Инструменты тестирования в KMP

Kotlin Multiplatform поддерживает единый тестовый фреймворк kotlin.test, который предоставляет аннотации @Test, @BeforeTest, @AfterTest и assertions. Тесты в commonTest компилируются и выполняются на каждом таргете: JVM, Android Unit Test, iOS Simulator.

Зависимости и конфигурация

// shared/build.gradle.kts:
kotlin {
    androidTarget()
    iosX64(); iosArm64(); iosSimulatorArm64()
    jvm()

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))  // @Test, assertEquals, etc.
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
        }
    }
}

Базовые тесты в commonTest

// src/commonTest/kotlin/FormattersTest.kt
class SalaryFormatterTest {

    @Test
    fun formatSalaryWithBothBounds() {
        val salary = Salary(min = 100_000, max = 200_000, currency = "RUB")
        assertEquals("100000–200000 RUB", formatSalary(salary))
    }

    @Test
    fun formatSalaryMinOnly() {
        val salary = Salary(min = 100_000, max = null, currency = "USD")
        assertEquals("from 100000 USD", formatSalary(salary))
    }

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

    @Test
    fun validEmailPassesValidation() {
        assertTrue(validateEmail("user@example.com"))
    }
}

Тестирование suspend-функций

// src/commonTest/kotlin/UserRepositoryTest.kt
class UserRepositoryTest {

    private lateinit var repo: UserRepository
    private lateinit var fakeApi: FakeUserApi

    @BeforeTest
    fun setup() {
        fakeApi = FakeUserApi()
        repo = UserRepository(fakeApi)
    }

    @Test
    fun loadUsersReturnsSortedList() = runTest {
        fakeApi.users = listOf(
            User("2", "Bob"),
            User("1", "Alice")
        )
        val result = repo.getUsers()
        assertEquals("Alice", result.first().name)
    }

    @Test
    fun loadUsersThrowsOnNetworkError() = runTest {
        fakeApi.shouldThrow = true
        assertFailsWith<NetworkException> {
            repo.getUsers()
        }
    }

    // Тест с контролем времени:
    @Test
    fun cacheExpiresAfter5Minutes() = runTest {
        repo.getUsers()  // первый запрос — кэшируется
        advanceTimeBy(4 * 60 * 1000)  // 4 минуты
        repo.getUsers()  // второй запрос — из кэша
        assertEquals(1, fakeApi.callCount)

        advanceTimeBy(2 * 60 * 1000)  // ещё 2 минуты
        repo.getUsers()  // кэш истёк — новый запрос
        assertEquals(2, fakeApi.callCount)
    }
}

Фейковые реализации вместо моков

// src/commonTest/kotlin/fakes/FakeUserApi.kt
class FakeUserApi : UserApi {
    var users: List<User> = emptyList()
    var shouldThrow: Boolean = false
    var callCount: Int = 0

    override suspend fun fetchUsers(): List<User> {
        callCount++
        if (shouldThrow) throw NetworkException("Network error")
        return users
    }

    override suspend fun getUser(id: String): User =
        users.find { it.id == id } ?: throw NotFoundException(id)
}

Mockative для автогенерации моков

// build.gradle.kts:
commonTest.dependencies {
    implementation("io.mockative:mockative:2.2.2")
}
// plugins:
id("com.google.devtools.ksp")

// Тест:
@Mock
val userApi = mock(classOf<UserApi>())

@Test
fun callsApiOnce() = runTest {
    given(userApi).coroutine { fetchUsers() }.thenReturn(listOf(User("1", "Alice")))

    val repo = UserRepository(userApi)
    repo.getUsers()

    verify(userApi).coroutine { fetchUsers() }.wasCalled()
}

Запуск тестов

// JVM (самый быстрый, без эмулятора):
./gradlew jvmTest

// Android Unit Tests:
./gradlew testDebugUnitTest

// iOS Simulator (требует macOS):
./gradlew iosSimulatorArm64Test

// Все таргеты:
./gradlew allTests

Подводные камни

  • Использование runBlocking вместо runTest в commonTest — на Kotlin/Native runBlocking на Main-потоке может дедлочить; runTest — правильная замена;
  • Реальный delay() в тестируемом коде без advanceTimeBy() — тесты выполняются в реальном времени и замедляют CI в десятки раз;
  • Использование @BeforeClass (JUnit4) в commonTest — аннотация отсутствует в kotlin.test; используйте @BeforeTest;
  • Прямое использование мока Mockito или MockK в commonTest — они работают только на JVM; для KMP нужны Mockative или ручные фейки;
  • Тестирование конкретных actual-реализаций из commonTest — тест получает JVM-реализацию, а не iOS; тесты actual пишите в платформенных test source sets;
  • Пропуск @AfterTest для закрытия БД/клиента — на Kotlin/Native утечки между тестами могут вызвать IncorrectDereferenceException;
  • Зависимость kotlinx-coroutines-test добавлена только в jvmTest вместо commonTest — тесты корутин не компилируются на iOS-таргете.

Common mistakes

  • Объяснять «тестирование common KMP-кода» только как синтаксис и не описывать поведение runtime/compiler.
  • Игнорировать важный риск: Если тестировать только Android, iOS actual-код может не компилироваться или вести себя иначе.
  • Давать пример без edge case: отмены, null, recomposition, platform boundary или ошибки.

What the interviewer is testing

  • Формулирует суть темы «тестирование common KMP-кода» своими словами и связывает ее с кодом.
  • Называет механизм: kotlin.test дает общий API, но окружение, dispatcher, database driver и file system нужно подбирать по target.
  • Видит production-последствие: Если тестировать только Android, iOS actual-код может не компилироваться или вести себя иначе.

Sources

Related topics