Skip to content

多平台测试框架

KMP 项目的测试框架需要支持多平台运行。本文介绍常用的测试框架和最佳实践。

kotlin.test 框架

Kotlin 官方提供的跨平台测试框架,是 KMP 项目的首选。

基础使用

kotlin
// commonTest
import kotlin.test.*

class MathUtilsTest {
    @Test
    fun testAdd() {
        assertEquals(5, add(2, 3))
    }
    
    @Test
    fun testDivide() {
        assertEquals(2.0, divide(6.0, 3.0))
    }
    
    @Test
    fun testDivideByZero() {
        assertFailsWith<ArithmeticException> {
            divide(1.0, 0.0)
        }
    }
}

断言方法

方法用途
assertEquals(expected, actual)值相等
assertTrue(condition)条件为真
assertFalse(condition)条件为假
assertNull(value)值为 null
assertNotNull(value)值非 null
assertFailsWith<T>抛出指定异常
assertContains(collection, element)包含元素

测试生命周期

kotlin
class UserRepositoryTest {
    private lateinit var repository: UserRepository
    private lateinit var database: Database
    
    @BeforeTest
    fun setup() {
        database = createTestDatabase()
        repository = UserRepository(database)
    }
    
    @AfterTest
    fun teardown() {
        database.close()
    }
    
    @Test
    fun testGetUser() {
        // 测试逻辑
    }
}

Kotest 框架

功能更丰富的测试框架,提供多种测试风格。

依赖配置

toml
[versions]
kotest = "5.9.1"

[libraries]
kotest-framework = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }

测试风格

kotlin
// String Spec 风格
class UserServiceTest : StringSpec({
    "should create user successfully" {
        val user = service.createUser("Alice")
        user.name shouldBe "Alice"
    }
    
    "should throw error for empty name" {
        shouldThrow<IllegalArgumentException> {
            service.createUser("")
        }
    }
})

// Fun Spec 风格
class CalculatorTest : FunSpec({
    test("addition works") {
        calculator.add(2, 3) shouldBe 5
    }
    
    context("division") {
        test("normal division") {
            calculator.divide(6, 3) shouldBe 2
        }
        
        test("division by zero throws") {
            shouldThrow<ArithmeticException> {
                calculator.divide(1, 0)
            }
        }
    }
})

Matchers

kotlin
import io.kotest.matchers.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.string.*

@Test
fun testMatchers() {
    // 数值 matchers
    result shouldBe 42
    count shouldBeGreaterThan 10
    price shouldBeLessThan 100.0
    
    // 字符串 matchers
    name shouldStartWith "Alice"
    email shouldContain "@"
    message shouldMatch Regex("\\d+")
    
    // 集合 matchers
    list shouldContain user
    list shouldHaveSize 3
    list shouldContainExactly listOf(1, 2, 3)
    
    // 类型 matchers
    obj shouldBeInstanceOf<User>()
    value.shouldNotBeNull()
}

MockK (仅 JVM/Android)

JVM 平台的 Mock 框架。

基础用法

kotlin
// androidTest / jvmTest
import io.mockk.*

class UserRepositoryTest {
    @Test
    fun testWithMock() {
        val api = mockk<UserApi>()
        
        // 设置 mock 行为
        coEvery { api.fetchUser("123") } returns User("123", "Alice")
        
        val repository = UserRepository(api)
        val user = runBlocking { repository.getUser("123") }
        
        assertEquals("Alice", user.name)
        
        // 验证调用
        coVerify(exactly = 1) { api.fetchUser("123") }
    }
}

跨平台 Fake 对象

Mock 库支持有限,推荐使用 Fake 对象:

kotlin
// commonTest
class FakeUserApi : UserApi {
    var shouldFail = false
    var responseDelay = 0L
    val callHistory = mutableListOf<String>()
    
    override suspend fun fetchUser(id: String): User {
        callHistory.add("fetchUser($id)")
        
        if (responseDelay > 0) {
            delay(responseDelay)
        }
        
        if (shouldFail) {
            throw IOException("Network error")
        }
        
        return User(id, "Test User")
    }
}

class UserRepositoryTest {
    @Test
    fun testWithFake() = runTest {
        val api = FakeUserApi()
        val repository = UserRepository(api)
        
        val user = repository.getUser("123")
        
        assertEquals("Test User", user.name)
        assertEquals(listOf("fetchUser(123)"), api.callHistory)
    }
    
    @Test
    fun testNetworkError() = runTest {
        val api = FakeUserApi().apply {
            shouldFail = true
        }
        val repository = UserRepository(api)
        
        assertFailsWith<IOException> {
            repository.getUser("123")
        }
    }
}

数据驱动测试

参数化测试

kotlin
// Kotest
class CalculatorTest : StringSpec({
    listOf(
        2 to 3 to 5,
        10 to 5 to 15,
        -1 to 1 to 0
    ).forEach { (input, expected) ->
        "adding ${input.first} and ${input.second} should equal $expected" {
            calculator.add(input.first, input.second) shouldBe expected
        }
    }
})

// kotlin.test
class MathTest {
    @Test
    fun testMultipleInputs() {
        val testCases = listOf(
            Triple(2, 3, 6),
            Triple(4, 5, 20),
            Triple(0, 10, 0)
        )
        
        testCases.forEach { (a, b, expected) ->
            assertEquals(expected, multiply(a, b),
                "multiply($a, $b) should equal $expected")
        }
    }
}

测试覆盖率

Kover 配置

kotlin
// build.gradle.kts
plugins {
    id("org.jetbrains.kotlinx.kover") version "0.8.3"
}

kover {
    reports {
        total {
            xml {
                onCheck = true
            }
            html {
                onCheck = true
            }
        }
    }
}

生成报告

bash
./gradlew koverHtmlReport
# 报告位于 build/reports/kover/html/index.html

平台特定测试

Android Instrumented Tests

kotlin
// androidInstrumentedTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class AndroidStorageTest {
    @Test
    fun testSharedPreferences() {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        val storage = AndroidStorage(context)
        
        storage.save("key", "value")
        assertEquals("value", storage.load("key"))
    }
}

iOS Tests

kotlin
// iosTest
import kotlin.test.Test
import platform.Foundation.NSUserDefaults

class IosStorageTest {
    @Test
    fun testUserDefaults() {
        val storage = IosStorage()
        
        storage.save("key", "value")
        assertEquals("value", storage.load("key"))
    }
}

测试策略

测试金字塔

     E2E Tests (5%)

  Integration Tests (15%)

   Unit Tests (80%)

测试分类

单元测试 (commonTest)

  • 纯业务逻辑
  • 数据转换
  • 工具函数

集成测试 (platformTest)

  • API 集成
  • 数据库操作
  • 平台特定功能

E2E 测试 (UI Tests)

  • 用户流程
  • 跨页面交互

最佳实践

✅ 实践 1:共享测试代码

kotlin
// commonTest
abstract class StorageTest {
    abstract fun createStorage(): Storage
    
    @Test
    fun testSaveAndLoad() {
        val storage = createStorage()
        storage.save("key", "value")
        assertEquals("value", storage.load("key"))
    }
}

// androidTest
class AndroidStorageTest : StorageTest() {
    override fun createStorage() = AndroidStorage(context)
}

// iosTest
class IosStorageTest : StorageTest() {
    override fun createStorage() = IosStorage()
}

✅ 实践 2:使用 Fake 替代 Mock

Fake 对象跨平台兼容性更好:

kotlin
interface Logger {
    fun log(message: String)
}

// 测试用 Fake
class FakeLogger : Logger {
    val logs = mutableListOf<String>()
    
    override fun log(message: String) {
        logs.add(message)
    }
}

✅ 实践 3:测试命名规范

kotlin
// ✅ 清晰的测试名称
@Test
fun `getUserById should return user when id exists`()

@Test
fun `getUserById should throw NotFoundException when id does not exist`()

// ❌ 模糊的测试名称
@Test
fun test1()

@Test
fun testGetUser()

✅ 实践 4:独立测试

kotlin
// ✅ 每个测试独立
@BeforeTest
fun setup() {
    database = createTestDatabase()  // 每次创建新实例
}

// ❌ 测试间共享状态
companion object {
    val sharedDatabase = createDatabase()  // 危险
}

选择合适的测试框架和策略,可以确保 KMP 项目在所有平台上的质量和稳定性。