Robolectric 的单元测试速度, JVM 内跑 Android

Robolectric 的单元测试速度, JVM 内跑 Android

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 的事务确实会提交,RecyclerViewLayoutManager 确实会计算布局。第二,它只保证行为近似,不保证和真机完全一致。比如 Shadow 的 Canvas 实现不会真的画像素,只是记录调用了哪些 drawTextdrawRect,所以基于像素颜色的测试在 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 ContextLifecycleFusedLocationProviderClient,还要处理 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 是真实的 LifecycleRegistryhandleLifecycleEvent(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() 来手动推进时间。ObjectAnimatorPropertyValuesHolder 有些类型在 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=stderrrobolectric.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=--none

manifest=--none 避免 Robolectric 尝试合并 manifest,省掉一堆资源路径问题。如果你的测试确实需要 manifest 里的组件声明(比如测试 Activityintent-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=truerobolectric.dependency.dir 之类的,那些是 CI 环境优化项,本地开发先跑起来更重要。




Robolectric 的文档在 robolectric.org,GitHub 仓库的 robolectric-samples 目录有几个完整示例项目。遇到 Shadow 行为不符合预期时,直接去源码里搜 ShadowXXX 的实现,比翻文档快得多——这也是开源工具的好处,没有黑箱。

F-Droid 开源应用市场的使用体验 2026-07-07
NetworkSecurityConfig 的证书固定,配置注意事项 2026-07-08

评论区