Robolectric 的单元测试速度, JVM 内跑 Android
Robolectric 的单元测试速度,JVM 内跑 Android
一个真实的痛点:Android 单元测试的启动成本
去年维护一个中型项目时,我们的 CI 流水线跑完全部单元测试需要 14 分钟。其中真正的测试执行时间不到 3 分钟,剩下的 11 分钟全花在启动模拟器和安装测试 APK 上。那时候用的是 Firebase Test Lab 的虚拟设备,单台机器按分钟计费,一个月测试账单接近 800 美元。
团队尝试过几个方案:把部分测试改成纯 JVM 的 Mockito 测试,但碰到 android.graphics.Bitmap 就报错;用 Hilt 的测试替身,配置复杂而且经常和真实组件行为不一致;也试过 Android Studio 的 Gradle Managed Device,启动速度比传统模拟器快一些,但冷启动仍然要 40 秒以上。
真正让我开始认真看 Robolectric 的,是一个特别具体的场景。我们需要测试一个自定义 View 的测量逻辑,这个 View 继承自 ConstraintLayout,内部根据子 View 的可见性动态调整 LayoutParams。写成 Espresso 测试的话,必须跑在真机或模拟器上,而这段逻辑本身完全不涉及渲染、硬件加速或者系统服务。把它放到 JVM 上跑,理论上只需要一个能解析 XML 属性、能执行 measure() 和 layout() 的 Android 环境就够了。
Robolectric 做的就是这件事:在 JVM 里提供一个足够真实的 Android 运行时,让 android.* 包下的代码能直接执行,不需要完整的 Android 系统镜像。
Robolectric 到底是什么:不是模拟器,是影子对象
很多人第一次听说 Robolectric 会误以为它是"轻量级模拟器",这个理解完全错了。Robolectric 不会启动任何 QEMU 进程,不加载 system.img,也不跑 Zygote。它的核心机制叫 Shadow 对象,用字节码操作替换了 Android 框架层的原生实现。
具体点说,当你代码里调用 new TextView(context) 时,Robolectric 的 SandboxClassLoader 会拦截这个类的加载,把 android.widget.TextView 替换成一个经过 instrument 的版本。这个版本里的 native 方法(比如 nDraw() 相关的底层调用)被替换成了 Java 实现,或者干脆是空操作。同时,Robolectric 会创建一个 ShadowTextView 对象,用来维护 TextView 的状态——比如 getText() 返回什么、setText() 之后触发了哪些监听器。
这个设计决定了 Robolectric 的几个关键特性。第一,它跑的是真实的 Android 框架代码,不是 mock。你的 Activity.onCreate() 确实会被执行,FragmentManager 的事务确实会提交,RecyclerView 的 LayoutManager 确实会计算布局。第二,它只保证行为近似,不保证和真机完全一致。比如 Shadow 的 Canvas 实现不会真的画像素,只是记录调用了哪些 drawText、drawRect,所以基于像素颜色的测试在 Robolectric 上跑不了。
当前最新稳定版本是 4.11.1,需要配合 AGP 8.0+ 和 JDK 17 使用。GitHub 仓库在 robolectric/robolectric,Apache 2.0 协议,完全免费。Google 官方维护,不是第三方社区项目,这点很重要——意味着它的版本跟进速度有保障,Android 14(API 34)的支持在正式版发布前就有了预览分支。
配置细节:Gradle 里的几个关键决策
引入 Robolectric 本身很简单,test 依赖加一行 testImplementation 'org.robolectric:robolectric:4.11.1' 就行。但实际项目里要让测试稳定跑起来,有几个配置点需要专门处理。
最核心的是 @Config 注解里的 sdk 参数。Robolectric 支持多个 API level 的 Shadow 实现,默认行为是跑一组测试覆盖多个 SDK 版本——这听起来很好,但会让测试时间成倍增加。我们的做法是显式锁定 sdk = 34,只在需要验证特定版本兼容性时才用 @Config(sdk = [28, 34]) 这样的多版本配置。这个决策省了一半的测试时间。
另一个容易踩坑的是 application 参数。Robolectric 默认会尝试实例化你的 Application 子类,如果里面在 onCreate() 里做了网络请求或者读取了需要运行时权限的内容,测试会直接崩溃。我们项目的 MyApplication 里集成了 Firebase Crashlytics 的初始化,Robolectric 环境下没有真实的 Google Play 服务,这部分必须绕过。解决方式是在 src/test 下创建一个 TestApplication 继承 Application,用 @Config(application = TestApplication.class) 指定。更干净的做法是用 Hilt 的 @CustomTestApplication,但 Hilt 的测试配置又是另一个话题了。
资源合并的问题也花了我不少时间。Robolectric 4.0 之后改用二进制资源模式(binaryResourcesMode = true),需要 AGP 把资源编译成 ap_ 格式。如果项目里还有 aaptOptions 的自定义配置,或者用了 shrinkResources,测试运行时可能找不到 R.id.xxx。报错信息通常是 android.content.res.Resources$NotFoundException,但根因不是资源真的缺失,而是 Robolectric 的资源加载路径和 Gradle 的合并输出目录没对上。检查 build/intermediates/merged_res/ 下的内容,确认 R.txt 存在且内容正确,是排查这类问题的起点。
实际能测什么:我们的测试分层实践
说清楚 Robolectric 能做什么,最好的方式是看我们怎么划分测试类型的。
第一层是纯 JVM 的 Kotlin/Java 单元测试,完全不碰 Android 类。业务逻辑、数据转换、算法这些放这里,用 JUnit 5 + MockK,毫秒级执行。
第二层是 Robolectric 测试,覆盖需要 Android 环境但不依赖真实硬件/渲染的场景。具体包括:Activity 和 Fragment 的生命周期流转(ActivityScenario 在 Robolectric 下可用)、ViewModel 配合 LiveData 的观察逻辑、自定义 View 的测量布局计算、SharedPreferences 的读写、BroadcastReceiver 的响应。我们有一个比较复杂的自定义图表 View,继承 View 自己实现 onDraw(),但测试只验证 onMeasure 输出的宽高和内部子 View 的排列位置,不验证绘制内容——这部分用 Robolectric 完全没问题。
第三层是 Espresso 测试,跑在真机或模拟器上,覆盖需要真实渲染、手势交互、系统对话框的场景。比如相机预览的 Surface 纹理、WebView 的 JavaScript 执行、通知栏的交互。
第四层是 Firebase Test Lab 的物理设备测试,覆盖特定厂商的定制行为,比如三星的 Edge 面板、小米的后台限制策略。
这个分层让 Robolectric 承担了原来需要模拟器的大部分测试,CI 时间从 14 分钟降到 4 分钟。测试总数没变,甚至第二层还增加了一些原来觉得"太麻烦没写"的边界情况测试。
具体案例:测试一个带 Lifecycle 的组件
我想展开讲一个实际的测试例子,说明 Robolectric 和纯 Mock 测试的区别。
我们有一个 LocationTracker 类,内部用 FusedLocationProviderClient 获取位置,用 LifecycleEventObserver 监听 Activity 生命周期来自动启停。这个类的接口大概长这样:
class LocationTracker(
private val context: Context,
private val lifecycle: Lifecycle
) : DefaultLifecycleObserver {
private val client = LocationServices.getFusedLocationProviderClient(context)
fun startTracking() { ... }
fun stopTracking() { ... }
override fun onResume(owner: LifecycleOwner) = startTracking()
override fun onPause(owner: LifecycleOwner) = stopTracking()
}如果用纯 Mockito 测试,你需要 mock Context、Lifecycle、FusedLocationProviderClient,还要处理 LocationServices.getFusedLocationProviderClient() 这个静态方法调用(得用 MockK 的 mockkStatic 或者 PowerMock)。更麻烦的是 Lifecycle 的状态流转——onResume 什么时候触发、触发时 Lifecycle.State 是什么,这些行为你 mock 出来的话,测的是你的 mock 设定,不是真实的 Lifecycle 逻辑。
用 Robolectric 的话,测试可以这样写:
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class LocationTrackerTest {
@Test
fun autoStopsWhenPaused() {
val activity = Robolectric.buildActivity(TestActivity::class.java)
.create()
.start()
.resume()
.get()
val tracker = LocationTracker(activity, activity.lifecycle)
tracker.startTracking()
// 验证 client 有活跃的 location callback
activity.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
// 验证 callback 被移除
}
}这里 Robolectric.buildActivity() 创建的是一个真实的 Activity 实例(虽然是 Shadow 实现的),它的 lifecycle 是真实的 LifecycleRegistry。handleLifecycleEvent(ON_PAUSE) 会触发所有观察者的 onPause(),和你真机上按 Home 键的行为一致。LocationServices.getFusedLocationProviderClient() 返回的是 Shadow 实现的 FusedLocationProviderClient,不会真的去连 Google Play 服务,但你可以验证 requestLocationUpdates() 和 removeLocationUpdates() 的调用参数。
这个测试在本地跑 200 毫秒,同样的逻辑写成 Espresso 测试需要 8 秒(启动 Activity 的时间)。
局限和坑:Shadow 不是真机
Robolectric 的文档里有一行小字容易被忽略:"Robolectric is a simulation, not an emulator." 这句话的代价我们在生产环境里付过几次。
最痛的一次和 WebView 有关。我们有一个页面嵌套了 WebView 加载本地 HTML,Robolectric 的 ShadowWebView 只实现了非常有限的 API:loadUrl() 会记录 URL 但不会真的加载内容,getTitle() 返回空字符串,evaluateJavascript() 直接抛 UnsupportedOperationException。当时有个测试在 Robolectric 上通过,但真机崩溃,原因是 WebView 的 JavaScript 接口对象在真机上需要 @JavascriptInterface 注解,而 Robolectric 的 Shadow 不检查这个注解。后来这个测试被降级到 Espresso 层。
SQLiteDatabase 的行为差异也坑过。Robolectric 默认用 SQLite 的 Java 实现(不是 Android 的 native SQLite),有些 SQL 语法支持不一样。我们有个查询用了 REPLACE() 函数,Robolectric 上正常,真机上 Android 8.0 以下的设备报语法错误。解决方式是显式配置 SQLiteMode.Mode.NATIVE,让 Robolectric 加载系统的 SQLite,但这又引入了平台差异问题。
动画是另一个灰色地带。ValueAnimator 在 Robolectric 里默认不执行,需要调用 ShadowLooper.runUiThreadTasksIncludingDelayedTasks() 来手动推进时间。ObjectAnimator 的 PropertyValuesHolder 有些类型在 Shadow 实现里没处理全。我们测试一个自定义进度动画时,发现 ofArgb() 在 Robolectric 上直接返回起始颜色,中间帧全丢了。最后这个测试改成了验证动画回调的调用次数和参数,不验证颜色插值结果。
通知渠道(Notification Channel)的测试也有陷阱。NotificationManager.createNotificationChannel() 在 Robolectric 上不会报错,但 getNotificationChannel() 返回的 NotificationChannel 实例有些字段是默认值,和真机上系统填充的值不同。我们测试渠道重要性是否设置正确时,Robolectric 上通过的测试在真机上发现重要性被系统降级了——因为真机会根据用户之前的交互历史调整渠道重要性,而 Robolectric 完全没有这个逻辑。
性能调优:为什么你的 Robolectric 测试还是慢
很多人配好 Robolectric 后发现测试并没有想象中快,常见的原因有几个。
SandboxClassLoader 的初始化是最大开销。每个测试进程第一次加载 Android 类时,Robolectric 要解析整个 framework 的字节码,生成 Shadow 的 instrument 版本。这个冷启动在 CI 的容器环境里尤其明显,因为每次构建都是全新环境,没有磁盘缓存。我们的解法是用 Gradle 的 testOptions.unitTests.isIncludeAndroidResources = true 配合 android.enableUnitTestBinaryResources=true,同时把 Robolectric 的 offlineMode 打开,让它用预下载的 Android jar 而不是每次从 Maven 拉取。更激进的方案是配置 Gradle 的 maxParallelForks,让测试类并行跑在多个 JVM 进程里——每个进程有自己的 Sandbox,但总体吞吐量更高。
Application 的重复初始化也是隐形杀手。默认行为下每个测试方法都会创建新的 Application 实例,如果你的 Application.onCreate() 里有重逻辑(比如 Dagger 组件构建、数据库初始化),这部分会执行几十次。Robolectric 4.8 之后支持 ApplicationReuseStrategy,可以配置 ACROSS_TESTS 让同一个 Application 实例复用。代价是测试之间可能有状态泄漏,需要确保每个测试的 tearDown 清理干净。
日志级别也意外影响速度。Robolectric 的 ShadowLog 默认会输出所有 Log.xxx 调用,如果测试代码或者依赖库里有大量日志,IO 开销不可忽视。我们在 robolectric.properties 里加了 robolectric.logging=stderr 和 robolectric.logging.enabled=false,关闭测试日志输出,省了大概 15% 的时间。
版本演进:4.10 的 Native Graphics 和 4.11 的 SDK 扩展
Robolectric 近两个版本有几个值得关注的变动。
4.10 引入了实验性的 Native Graphics 模式,用 Skia 的 Java 绑定来做真实的图形渲染。这意味着以前完全不能测的 Canvas 绘制逻辑,现在可以验证像素颜色了。开启方式是在 @Config 里加 graphicsMode = GraphicsMode.NATIVE。我们试过一个自定义 Drawable 的测试,用 Bitmap.getPixel() 验证渐变色的端点颜色,在 Native Graphics 模式下确实能拿到正确的 ARGB 值。但这个模式目前有几个限制:需要系统安装特定版本的 Skia 库,CI 的 Docker 镜像里要额外配置;和 ShadowCanvas 的 API 不兼容,老测试迁移有成本;性能比 Shadow 模式慢一个数量级,不适合大规模使用。
4.11 的重点是 SDK 扩展(SDK Extensions)支持。Android 14 开始,部分功能通过 Google Play 系统更新分发,不再绑定大版本。Robolectric 以前按 API level 分发完整的 Shadow 实现,现在需要处理"API 34 + Extension 7"这种组合版本。配置方式是通过 sdk = 34 配合 sdkExtensions = [SdkExtension(7, "com.google.android.modulemetadata")]。这个改动目前影响还不大,但长远看是架构上的必要调整,否则跟不上 Google 的模块化解耦趋势。
和同类工具的对比:为什么不是 Mockito Android 或 Hilt Test
市面上有几个和 Robolectric 定位接近的方案,值得说清楚差异。
Mockito 的 mockito-android 库允许在 Android 运行时创建 mock 对象,但它解决的是"在真机/模拟器上跑 mock"的问题,不是"在 JVM 上跑 Android 代码"。如果你已经在模拟器环境里,用 Mockito mock 框架层 API 是可行的,但启动模拟器的成本一点没省。反过来,Robolectric 不依赖 mock 框架,你可以纯用真实对象,也可以混用 Mockito——只是 Mockito 在 Robolectric 环境下有些限制,比如 mockito-inline 的字节码操作和 Robolectric 的 SandboxClassLoader 偶尔冲突。
Hilt 的测试基础设施提供了 HiltTestApplication 和 @BindValue 这样的工具,方便替换依赖注入的组件。但它本身不提供 Android 运行时环境,需要配合 Robolectric 或 Espresso 使用。我们项目的典型配置是:Robolectric 测试里用 Hilt 的 @HiltAndroidTest + HiltTestApplication,既能在 JVM 跑,又能控制注入的依赖。这个组合是官方推荐的,但配置复杂度确实高,新手容易在 Application 继承链和 @CustomTestApplication 的生成代码里绕晕。
Paparazzi 是 Square 开源的截图测试工具,也跑在 JVM 上,但它专注的是 UI 渲染验证,不是通用的单元测试框架。Paparazzi 用了 LayoutLib(Android Studio 预览同款引擎)来渲染 View,比 Robolectric 的 Shadow 更真实,但只支持特定的 Compose/View 版本组合,而且测试的是静态布局,不是交互逻辑。我们的做法是:布局回归用 Paparazzi,交互和生命周期用 Robolectric,端到端用 Espresso,三层互补。
个人观点:Robolectric 的性价比曲线
用 Robolectric 两年下来,我的感受是它的价值不是"快",而是"足够快的前提下足够真实"。纯 Mockito 测试更快,但 mock 出来的 Android 框架行为经常和真机不一致;Espresso 测试更真实,但慢到让人不想写。Robolectric 卡在中间这个 sweet spot,让"写测试"这件事的心理门槛降到和纯 JVM 测试差不多。
但它不是银弹。Shadow 实现的近似性意味着你始终要留一层 Espresso 测试作为最终验证,不能把所有测试都压到 Robolectric 层。我们现在的比例大概是 60% 纯 JVM / 30% Robolectric / 10% Espresso,这个比例是在付过几次线上 bug 的学费之后调整出来的。
另一个观察是 Robolectric 的维护活跃度直接影响采用信心。Google 内部也在用(Search app 的测试里有公开引用),核心开发者是 Google 员工,版本跟进速度比社区驱动的替代品可靠。但这也意味着它的路线图受 Google 优先级影响,比如 Wear OS 的 Shadow 支持就一直比较弱,TV 相关的测试 helper 也缺失。
最后的配置参考:一个能直接用的最小集合
如果你现在就想在项目上试 Robolectric,这是经过我们项目验证的最小配置集合,去掉了很多网上流传但已经过时或冗余的设置。
build.gradle.kts 的 test 依赖:
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.test.ext:junit:1.1.5")src/test/resources/robolectric.properties:
sdk=34
application=com.example.TestApplication
manifest=--nonemanifest=--none 避免 Robolectric 尝试合并 manifest,省掉一堆资源路径问题。如果你的测试确实需要 manifest 里的组件声明(比如测试 Activity 的 intent-filter),去掉这行。
测试基类:
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34], application = TestApplication::class)
abstract class RobolectricTestBase {
@get:Rule
val hiltRule = HiltAndroidRule(this)
}TestApplication 最小实现:
class TestApplication : Application()就这些。网上很多教程会让你配 robolectric.offlineMode=true、robolectric.dependency.dir 之类的,那些是 CI 环境优化项,本地开发先跑起来更重要。
Robolectric 的文档在 robolectric.org,GitHub 仓库的 robolectric-samples 目录有几个完整示例项目。遇到 Shadow 行为不符合预期时,直接去源码里搜 ShadowXXX 的实现,比翻文档快得多——这也是开源工具的好处,没有黑箱。