《KMP 共享层测试:commonTest 双端跑通》
要点
KMP 项目的测试分三层:commonTest 测共享逻辑、平台源集测试(androidUnitTest、iosTest)测各端 actual 实现、UI 层测 Compose 界面。这篇只做第一层——把 commonTest 写起来,让同一份用例在 Android 和 iOS 两个目标上都跑绿。这也是收益最高的一层:一份断言买两次保险,而共享逻辑恰恰是 KMP 项目最值得保护的部分。
本文不做:Compose UI 测试、Robolectric 完整配置(一句带过)、CI 双平台矩阵搭建,条件编译注解(@ConfigAnnotation 之类)属于进阶玩法,我的项目还没用到,不写。
commonTest 放什么
官方向导建的工程里,测试源集已经存在:src/commonTest/kotlin。规则一句话讲完——commonTest 与 commonMain 同级约束,里面只能用 commonMain 能用的 API。java.* 不存在,android.* 不存在;协程、kotlinx-serialization、kotlin.test 都可以用。所以它天然适合测这几类东西:
- 数据模型与映射函数(DTO 转 Model、序列化往返);
- 纯工具函数(格式化、校验、金额计算);
- Repository 接口的协议(配合内存 fake 实现);
- 共享 ViewModel 的状态流转(配合 kotlinx-coroutines-test);
- Flow 的发射顺序与变换逻辑。
平台有差异但签名一致的辅助逻辑,可以放中间源集 src/iosAndAndroidMain 的测试里——比如两端共用一个 fake 存储实现时,只写一份。再往下拆的 androidMain、iosMain 各自的测试,就测各自的 actual,不混。
kotlin.test:断言一套通吃
kotlin-test 依赖向导一般已经带上(commonTest 里配 kotlin-test 就够了)。用例长这样:
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFailsWith
class TodoMapperTest {
@Test
fun dtoToModel() {
val dto = TodoDto(id = "1", title = "买咖啡", done = false)
val model = dto.toModel()
assertEquals("买咖啡", model.title)
assertTrue(!model.done)
}
@Test
fun badInput() {
assertFailsWith<IllegalArgumentException> {
TodoDto(id = "", title = "买咖啡", done = false).toModel()
}
}
}
跑 Android 目标,预期输出:
./gradlew :shared:testDebugUnitTest
TodoMapperTest > dtoToModel PASSED
TodoMapperTest > badInput PASSED
BUILD SUCCESSFUL
报告落在 shared/build/reports/tests 下面,HTML 版浏览器打开能看每个目标的通过情况。断言函数与 JVM 侧习惯一致:assertEquals、assertNull/assertNotNull、assertFailsWith 断异常、浮点用带 delta 的重载。条目速查翻参考字典的测试条目组,不必背。
从 JUnit 迁过来的对照
从 JVM 后端转来做 KMP 的人最容易在 import 上栽跟头:@Test 要来自 kotlin.test 而不是 org.junit.jupiter.api——import 错包是最常见的第一个报错,报错信息本身会提示”Test 未解析”,改 import 即可。其余对照:assertEquals(expected, actual) 参数顺序与 JUnit 一致,报错信息同样带 expected/actual 两栏;beforeTest/afterTest 对应 setup/teardown。Kotest 的 shouldEqual 风格在 commonTest 也能用,但 kotlin.test 是官方默认且依赖最薄,团队统一用它,少一套依赖就少一次升级。
序列化往返:一条用例守住 double conversion
共享层最常见的翻车点是序列化配置(Json 的 ignoreUnknownKeys、字段默认值)。一条往返用例守住它:
@Test
fun jsonRoundTrip() {
val json = Json { ignoreUnknownKeys = true }
val original = TodoDto(id = "2", title = "跑步", done = false)
val encoded = json.encodeToString(original)
val decoded = json.decodeFromString<TodoDto>(encoded)
assertEquals(original, decoded)
}
这个用例同时验证了编码与解码的可逆性,后端加字段、改字段名时它会第一个红——比上线后用户报”列表空白”便宜得多。
接口协议测试:一份用例测所有实现
共享层的另一个标准形态是 commonMain 里定义仓库接口、各端 actual 出真实现。给接口写一个”协议测试套件”,内存 fake 实现一份,将来任何新实现都继承这套用例:
abstract class TodoRepositoryContract {
abstract fun repo(): TodoRepository
@Test
fun insertThenFind() = runTest {
val r = repo()
r.insert(Todo(id = "t1", title = "写作", done = false))
assertEquals(listOf("写作"), r.all().map { it.title })
}
@Test
fun emptyAfterClear() = runTest {
val r = repo()
r.insert(Todo(id = "t2", title = "健身", done = true))
r.clear()
assertTrue(r.all().isEmpty())
}
}
class InMemoryTodoRepositoryTest : TodoRepositoryContract() {
override fun repo() = InMemoryTodoRepository()
}
runTest 来自 kotlinx-coroutines-test,commonMain 可用,suspend 函数不用再套 GlobalScope。这个模式把接口语义钉死在 commonTest:actual 层各自实现时漏掉边界(去重、排序、空集合),当场红。
Flow 与虚拟时间
测 Flow 用 first() 取首个值,测连续发射用 toList 收集。涉及 delay 的逻辑用 runTest 的虚拟时钟——它把真实等待压缩到瞬间,advanceUntilIdle() 一口气推进所有挂起的任务:
@Test
fun debouncedQuery() = runTest {
val vm = SearchViewModel()
vm.query.emit("k")
advanceUntilIdle()
assertEquals("k", vm.results.first())
}
验收方式:给这个用例计时(IDE 里有耗时),应该在毫秒级——如果跑了好几秒,说明你把真实 delay 跑进去了,调度器没用对。
测试素材:一个工厂函数省一半代码
用例写多了会发现一半篇幅在造数据。给 DTO 建一个带默认值的工厂函数,字段按需覆盖:
fun todo(
id: String = "t-" + Random.nextInt(100000).toString(),
title: String = "默认任务",
done: Boolean = false
) = Todo(id = id, title = title, done = done)
// 用例里只写关心的字段
r.insert(todo(title = "买牛奶"))
assertEquals("买牛奶", r.all().first().title)
好处不只是省字:新增字段时给工厂一个默认值,几十条用例一行不用改;某条用例显式传了某个字段,说明它”在乎”这个字段,测试意图也跟着清楚了。顺带一个细节:commonTest 里没有 java.util.UUID,后缀我用 kotlin.random.Random 拼——这本身就是”commonMain 没有 JDK”约束的一次日常演练。
共享 ViewModel 的状态流转
共享 ViewModel 是 commonTest 的重头戏,因为它正是你选 KMP 最想保护的那份资产。写法:给构造器传 fake 仓库,状态断言跟着用户操作走:
@Test
fun loadFlow() = runTest {
val vm = TodoListViewModel(repo = FakeRepo(listOf(Todo("a", "写作", false))))
assertTrue(vm.state.value is Loading)
vm.load()
advanceUntilIdle()
val s = vm.state.value
assertTrue(s is Loaded)
assertEquals(1, (s as Loaded).items.size)
}
验收手法:把 FakeRepo 换成”先 delay 再返回”的版本,用例依然绿且耗时不变——虚拟时钟吃掉了 delay;若把 runTest 换成 runBlocking,这条用例会真等,耗时立刻暴露差别。错误分支也照这个骨架写:FakeRepo 换成抛异常的版本,断言 state 落在 Error 且带原始消息。
Gradle 任务清单:每个目标一个
KMP 没有一个”跑所有测试”的统一入口,每个编译目标对应一个任务,常用就这几个:
./gradlew :shared:testDebugUnitTest # Android debug 侧单测
./gradlew :shared:testReleaseUnitTest # Android release 侧
./gradlew :shared:iosSimulatorArm64Test # Apple Silicon 模拟器架构
./gradlew :shared:iosX64Test # Intel Mac 模拟器架构
./gradlew :shared:allTest # 聚合:全部目标的测试
验收标准我定成三条:allTest 全绿;Android 报告里能看到 commonTest 的用例(不是只有 androidMain 的);故意在 commonTest 塞一个 assertEquals(1, 2) 跑 iOS 任务,确认它也红,再删掉恢复绿。”双端跑通”的意思就是它——只绿了 Android 不算数。
IDE 里跑:Android Studio 的 gutter 绿箭头对 commonTest 默认只跑 Android 目标,iOS 侧必须走 Gradle 任务(或 IDEA 的 KMP 运行配置选 iosSimulatorArm64)。这个差异记住,能省不少困惑。没 Mac 的话 iOS 目标任务跑不了,这与学习路线里说的前置条件一致,别指望 CI 之外凭空变出 Xcode。
节奏上我分两层:testDebugUnitTest 快,挂进每次提交前跑;allTest 里 iOS 任务要过一遍 Kotlin/Native 编译,慢一个量级,挂进每天跑一次(或提交到主分支时跑)的口径。全量塞进提交前检查的结果只有一个——开发者开始用 -x test 跳过它,分层跑才可持续。
平台特有逻辑怎么办
actual 实现里离不开平台 API 的代码(Android 的 Context、SharedPreferences,iOS 的 NSUserDefaults),测试去处分两个:逻辑重的部分继续上提进 commonMain 测,这才是长期正确的方向——上次我把 Android 端”读设置并解析”的实现拆成”平台读字符串”加”common 解析”,解析那半进 commonTest,平台半边只剩一个 thin wrapper,不值得测。确实绕不开 Android API 的,androidUnitTest 源集配 Robolectric(org.robolectric:robolectric,配置量不小,本文只指路不展开)。iOS 的 actual 测试放 iosTest 源集,跑法还是 iosSimulatorArm64Test。
常见坑
- 只绿了 Android 就提交:iOS 目标的编译错误要等 iosSimulatorArm64Test 跑了才暴露。用 allTest 或把两个任务都挂进提交前习惯。
- commonTest 里 import 平台 API 编译失败:这不是 bug 是特性——它在提醒你这段逻辑要么进 commonMain,要么挪去平台源集测试。
- kotlin-test 依赖漏配:报 Test 类找不到。检查 commonTest 的 dependencies 里有没有 kotlin-test。
- 用例互相污染:共享单例状态没重置。每个用例自建实例,别用 object 单例直接测。
- 在 Windows/Linux 上跑 ios 任务:iOS 目标的测试只能在 macOS host 上跑,Windows 报”目标不存在”不是配置错了,是平台不支持。
验收清单
- commonTest 至少含:一个纯函数测试、一个接口协议测试(fake 实现)、一个序列化往返、一个 assertFailsWith 异常测试。
- allTest(或 Android 加 iOS 两个任务)全绿;塞必败用例后两端任务都红,删掉后恢复。
- 协程代码用 runTest,测试目录 grep 不到 Thread.sleep 和 GlobalScope。
- commonTest 与 iosAndAndroidMain 的测试文件里 grep 不到 java. / android. 的 import。
存量工程从哪里下手:按”改哪个文件就补哪个文件的测试”滚动推进,不搞一次性补齐——一次性补出来的用例往往是照着现有实现抄的断言,实现怎么写它就怎么断,改实现必改测试,形同虚设。我的口径是:新代码强制带测试,旧代码按接触频率自然翻绿,三个月后核心路径的覆盖自己就起来了。
这套测试往哪个工程上叠:直接用双端待办实战做完的工程,shared 模块、仓库接口、DTO 都是现成的。
本文数据核对时间:2025-08-11:Kotlin 2.2.x(2.2.0 于 2025-06 GA)+ Compose Multiplatform 1.8.x(1.8.0 于 2025-05 GA,iOS 目标自此稳定);kotlin-test、kotlinx-coroutines-test 随各自主线发布,Gradle 测试任务名以 kotlinlang.org 文档为准。