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/NativerunBlockingна 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-код может не компилироваться или вести себя иначе.