Kotlin 的 Power Assert 提案,测试断言写起来能爽多少
Kotlin 的 Power Assert 提案,测试断言写起来能爽多少
写 Android 单元测试最窒息的瞬间,往往不是逻辑出错,而是 CI 日志里弹出一行 expected:<User(name=John, age=30)> but was:<User(name=John, age=31)>。你盯着这个报错,心里清楚这不过是某个 data class 里一个字段差了一点点,但具体是哪一个嵌套对象里的哪一层属性?自己慢慢挖吧。JUnit 的 assertEquals 在这种场景下基本就是瞎子摸象,而 Kotlin 标准库自带的 assert() 更是个摆设——抛出来的 AssertionError 连比较双方是谁都不肯告诉你,就给你留个 Assertion failed 的冷屁股。
这种痛苦在 JVM 测试圈其实早就有解药。Groovy 系的开发者十几年前就在用 Power Assert,写个 assert x + y == z,失败时直接把 x、y、z 的展开值全部打印出来,树状结构一目了然。Spock 框架能在这几年成为 JVM 生态里写测试的标杆,有一半功劳得算在这内置的 Power Assert 上。Kotlin 开发者眼红了太多年,终于等到 JetBrains 那边有人认真提了个官方提案,想把这套能力收进 Kotlin 编译器或者至少以官方插件的形式落地。但这件事从社区呼声变成实际能用的东西,路径远比想象中曲折,而且在 Android 工程里接进来,未必有你想象中那么酸爽。
标准库 assert() 是个摆设,而 JUnit 那套在 Kotlin 里写得手疼
Kotlin 标准库里的 assert 函数本质上就是个内联的条件检查,源码在 kotlin/Assertions.kt 里,默认 JVM 下如果不开 -ea 参数甚至不会执行。即便你开了,它抛出来的异常信息也空洞得可怜。于是大部分 Android 项目要么回到 JUnit 4/5 的 assertEquals、assertTrue,要么引入 AssertJ、Google Truth 或者 Kotest 的断言 DSL。
AssertJ 在 Java 时代确实是个巨大的进步,assertThat(user.getName()).isEqualTo("John") 这种流式 API 比 JUnit 的静态方法可读性强不少。但到了 Kotlin 里,你常常要写一堆 assertThat(actual).isEqualTo(expected),遇到可空类型还得 .isNotNull 先过一遍,或者拆成三行。更烦的是断言失败时的信息:如果比较的是个嵌套很深的 data class,AssertJ 会帮你把 toString() 吐出来,但如果表达式里带方法调用,比如 assertThat(repo.findUserById(id).profile.bio.length).isGreaterThan(10),挂了之后你依然不知道 findUserById 返回的是不是 null,也不知道 profile 是不是空。
Google 给 Android 推的 Truth 库也没好到哪去。assertThat(user).isEqualTo(expected) 在对象层级不深时挺好用,可一旦你的领域模型里有三层以上的嵌套,失败日志照样是一坨展开的字符串。于是写测试的人学乖了,要么把链式调用拆成多个局部变量,每一步都加个非空检查;要么在 assertWithMessage 里手写一段描述。本来三行就能写完的断言,硬是为了可读性和调试性膨胀到十行。这种为了"断言挂了能看懂"而付出的额外代码量,在业务逻辑复杂的 Android 模块里简直是个隐性税。
Power Assert 的本质:编译器帮你做展开
Power Assert 解决的不是"怎么写断言更优雅",而是"断言挂了之后,我能不能一眼看出这个布尔表达式里每一块分别是什么值"。它的实现依赖于编译器层面的 AST 变换。拿 Groovy 的原始实现来说,当你写:
assert user.age >= 18 && user.name.startsWith("A")编译器会在编译期把这个表达式拆解,生成类似这样的中间代码:
def $a = user.age
def $b = $a >= 18
def $c = user.name
def $d = $c.startsWith("A")
def $e = $b && $d
if (!$e) {
throw new AssertionError("assert user.age >= 18 && user.name.startsWith(\"A\")\n }说白了,编译器把表达式树里的每个子节点都抽成临时变量,然后把变量名、源码位置和实际值格式化成一个可视化树。这不是运行时反射能做到的——反射只能拿到最终结果,没法在失败时还原出表达式每个分支的中间态。所以 Power Assert 必须是编译器插件或者语言内置特性。
Brian Norman 写的 kotlin-power-assert 第三方插件就是这个思路。它注册了一个 Kotlin 编译器插件,在代码生成阶段拦截 assert 调用,然后把表达式拆开。这个插件在 GitHub 上存在好几年了,版本号也迭代到了 0.13 左右(具体看发布时间),支持把 assert 或者自定义的断言函数包装成 Power Assert 风格。对于纯 Kotlin/JVM 项目,比如后端 Ktor 应用或者命令行工具,接这个插件相对轻松:在 Gradle 的 plugins 或者 buildscript 里加一行 com.bnorm.power.kotlin-power-assert,配置一下 powerAssert 块指定哪些函数需要被改造,基本就能跑。
Android 工程接第三方编译器插件,等于在 AGP 和 Kotlin 版本矩阵里走钢丝
但 Android 项目不是纯 Kotlin/JVM 项目。当你在一个使用了 AGP 8.1 或 8.2、Kotlin 1.9.20 以上的 Android 工程里引入第三方编译器插件时,你面对的版本兼容性矩阵足以让人脱层帽。
Android Gradle Plugin 每个大版本都对 Kotlin 编译器版本有隐性的要求范围。AGP 8.0 开始强制要求 JDK 17,到了 AGP 8.2 左右,官方开始推荐 Kotlin 1.9.x 系。而 Kotlin 2.0.0 在 2024 年 5 月正式发布,K2 编译器成为默认前端。这个切换对编译