关于 using-robolectric-correctly
面向 Android 开发者的 Robolectric 测试指南,介绍如何在 JVM 上运行依赖 Android 框架的测试。内容涵盖运行器选择、模拟 SDK 配置、shadow、Looper 控制,以及主机与设备间的测试共享。
主要功能
- 01选择 AndroidJUnit4 或 Robolectric 运行器
- 02用 @Config 配置 SDK 版本
- 03通过 shadow 模拟 Android 系统类
- 04清空暂停状态的 Looper 队列
- 05组织主机和设备测试源码
- 06启用 Android 资源加载
适用场景
- 01在 JVM 上测试 Android 框架代码
- 02修复未模拟方法或资源缺失导致的测试失败
- 03控制测试中的 Handler 和 Looper 任务
- 04在 JVM 与设备间运行共享测试逻辑
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill using-robolectric-correctly安装后在 Claude Code 中输入/using-robolectric-correctly确认已加载。
详细介绍
正确使用 Robolectric——在 JVM 上运行 Android,避免陷阱
Robolectric 通过替换为纯 Java 实现的系统类(“影子”),在 JVM 上模拟 Android。它运行很快(比模拟器快 10–100 倍),但不是模拟器:没有真正的 RenderThread、Binder 或 GPU。本指南明确了运行器的选择、@Config 矩阵、清空 Looper 的步骤,以及 AGP 7.2+ 中 sharedTest 的注意事项。关于在 Robolectric 上运行 Compose 的具体内容,请参阅 ../../../compose/synchronization/controlling-the-test-clock/SKILL.md。
适用场景
- 被测类导入了
android.*(Context、Resources、View、PackageManager、Looper),开发者希望在几秒内于 JVM 上测试,而不是花几分钟在模拟器上测试。 - 单元测试失败并提示
RuntimeException: Method ... not mocked(默认的纯 Android JAR 会导致此问题)——开发者需要 Robolectric 的影子实现。 - 单元测试失败并提示
Resources$NotFoundException——缺少testOptions.unitTests.includeAndroidResources。 - 开发者询问“AndroidJUnit4 和 RobolectricTestRunner,应该选哪个?”。
- 在 Robolectric 默认的
LooperMode.PAUSED下,Handler.postDelayed/ Looper 队列无法推进。 - 开发者询问如何在 JVM 和设备上运行同一份测试源代码(
sharedTest/androidHostTest+androidDeviceTest)。 - AGP 7.2+ 后出现构建错误:“Source directory ... already added to source set”,原因是经典的
sharedTestsrcDir共享模式。
不适用场景
- 被测类完全没有导入
android.*。Robolectric 会白白增加 1–3 秒的类加载开销——请使用纯 JUnit4。参阅../../runner/configuring-junit4-on-android/SKILL.md。 - 测试要验证像素正确性、波纹动画、截图差异、无障碍服务或
RenderThread时序。请在模拟器/设备上运行。参阅../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md。 - 测试涉及协程和
runTest,且不需要 Android 框架。请使用../../coroutines/testing-coroutines-with-runtest/SKILL.md。 - 测试涉及 Compose UI 渲染。主机测试环境由
../../../compose/setup/configuring-test-dependencies/SKILL.md配置,时钟语义请参阅../../../compose/synchronization/controlling-the-test-clock/SKILL.md。
前置条件
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.x")
testImplementation("androidx.test:core:1.7.0") // ApplicationProvider works on both runtimes
testImplementation("androidx.test.ext:junit:1.3.0") // androidx.test.ext.junit.runners.AndroidJUnit4
(docs/CORPUS.md §G.1;R7。)
android {
testOptions {
unitTests {
includeAndroidResources = true // non-negotiable
}
}
}
只要有任何测试(或任何由测试加载的类)访问 R.*、Resources、布局、主题或 res/ 中的内容,就必须启用 includeAndroidResources。否则,测试启动时资源查找会抛出异常(R7)。
Robolectric 4.x 支持从 SDK 21 到最新稳定版的 SDK。androidx Compose 主机测试将最低版本固定为 23——请以此作为唯一可信来源:
// androidx/compose/ui/ui-test/src/androidHostTest/.../Constants.kt
internal const val RobolectricMinSdk = 23
(R7。)
运行器选择——AndroidJUnit4 还是 RobolectricTestRunner
两种运行器都可用;它们对 @Config 的处理方式相同。
| 运行器 | 适用场景 |
|---|---|
androidx.test.ext.junit.runners.AndroidJUnit4 | **默认选择。**这是一个路由器:在 JVM 上将测试委派给 Robolectric,在设备上则委派给真正的 AndroidJUnit4ClassRunner。这是 sharedTest 模式的关键。 |
org.robolectric.RobolectricTestRunner | 仅当测试绝不能在设备上运行时使用——例如,直接使用 shadowOf(Looper.getMainLooper()).idle() 等 Robolectric 专用影子 API,或需要使用 ParameterizedRobolectricTestRunner 等 Robolectric 子类。 |
(R7;docs/CORPUS.md §G.6。)
import androidx.test.ext.junit.runners.AndroidJUnit4
@RunWith(AndroidJUnit4::class)
@Config(minSdk = 23)
class FooTest { /* runs under Robolectric on JVM, real runner on device */ }
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class LooperBoundTest { /* uses ShadowLooper APIs directly — JVM only */ }
重要:androidx.test.runner.AndroidJUnit4(runner 包,而非 ext.junit.runners)已标记为 @Deprecated(docs/CORPUS.md §G.2)。始终从 androidx.test.ext.junit.runners 导入。
@Config — sdk、qualifiers、application
@Config 可按类或方法配置模拟的 Android 环境。方法级配置会覆盖类级配置;子类会继承配置,也可以覆盖配置。
// Single SDK
@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
class FooTest
// SDK matrix — runs once per listed SDK
@Config(sdk = [21, 28, 33])
class BarTest
// Range — runs once at the project's targetSdk if it is >= minSdk
@Config(minSdk = 23)
class BazTest
// Per-method override
class QuxTest {
@Test @Config(sdk = [Build.VERSION_CODES.TIRAMISU])
fun onTiramisuOnly() { /* ... */ }
@Test @Config(qualifiers = "fr-rFR-w820dp") // locale + screen
fun frenchLayout() { /* ... */ }
@Test @Config(qualifiers = "+night") // additive: keep, force night
fun darkColors() { /* ... */ }
}
// Override the Application instance
@Config(application = MyTestApplication::class)
class WithFakeApp
(R7。)
限制:同一个注解中不能同时使用 sdk 和 minSdk/maxSdk。minSdk 和 maxSdk 可以组合使用。
CI 阶段的经验之谈:在提交门禁上优先使用 @Config(sdk = [<one SDK>]);将多 SDK 矩阵留给合并门禁。矩阵中的每个 SDK 都会重新运行整个类。
Shadows
Shadow 是适用于 JVM 的 Android 系统类伪实现。Robolectric 自带数百个 Shadow;你也可以通过 @Implements/@Implementation 编写自己的 Shadow。常见的有:
| Shadow | 用途 |
|---|---|
ShadowApplication | 检查已启动的服务、广播和已授予的权限。 |
ShadowLog | 捕获 android.util.Log 输出(ShadowLog.stream = System.out)。 |
ShadowSystemClock | 推进/冻结 SystemClock.elapsedRealtime() / uptimeMillis()。 |
ShadowLooper | 驱动消息队列;idleMainLooper()、runUiThreadTasksIncludingDelayedTasks()。 |
ShadowPackageManager | 添加/移除软件包、设置系统功能、查询可用的 intent。 |
ShadowAlarmManager | 检查已安排的闹钟而不触发它们。 |
ShadowNotificationManager | 检查已发布的通知和通知渠道。 |
ShadowInputManager | 为输入测试添加/移除 InputDevice。 |
ShadowContentResolver | 注册伪造的 provider,观察插入和查询操作。 |
Shadows.shadowOf(realObject) 会返回 Shadow 实例。使用静态导入,并依赖重载解析:
import androidx.test.core.app.ApplicationProvider
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowPackageManager
val app: Application = ApplicationProvider.getApplicationContext()
val shadowPm: ShadowPackageManager = shadowOf(app.packageManager)
shadowPm.setSystemFeature(PackageManager.FEATURE_CAMERA_ANY, true)
Looper 排空——最容易遗漏的步骤
在默认的 LooperMode.PAUSED 运行模式下,Robolectric 的主 looper 处于暂停状态。SUT 发布的 Handler 消息不会自动运行。请显式排空:
import android.os.Looper
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowLooper
import java.time.Duration
// Drain everything currently queued on the main looper.
shadowOf(Looper.getMainLooper()).idle()
// Run delayed tasks too (postDelayed et al).
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
// Advance virtual time by N ms; only handlers due before that time fire.
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
(R7。)
这与 Compose 的 MainTestClock.advanceTimeBy(...) 相辅相成,且彼此独立。Compose 时钟和平台 looper 是分开的。对于 Robolectric 上的 Compose,runComposeUiTest { … waitForIdle() } 会通过 RobolectricIdlingStrategy 为你驱动 looper;在 Compose 之外,请手动排空。
sharedTest / androidHostTest / androidDeviceTest
经典的 sharedTest 模式(见 2021 年的 Robolectric 博文)会将一个源目录同时放入 test 和 androidTest 下:
// LEGACY — broken on AGP 7.2+
android {
sourceSets {
String dir = 'src/sharedTest/'
test { java.srcDirs += dir + 'java' }
androidTest { java.srcDirs += dir + 'java' }
}
}
重要:从 AGP 7.2+ 开始,不能将同一个源目录放入多个 source set(Robolectric GitHub issue #7432;R7)。构建会报错:"Source directory ... already added to source set"。
现代替代方案(按优先顺序排列):
- AndroidX 风格的 KMP source set——通过
androidXMultiplatform插件,将commonTest->{androidHostTest, androidDeviceTest}。Compose 已在内部迁移到此模式:sourceSets { androidCommonTest { dependsOn(commonTest) } androidDeviceTest { dependsOn(androidCommonTest) } // instrumented APK androidHostTest { dependsOn(androidCommonTest) } // Robolectric on JVM } - 单独的 Gradle 模块,让
test和androidTest都以testImplementation/androidTestImplementation依赖它。 - 在配置时使用符号链接或复制源文件(最不推荐——不够稳健)。
androidHostTest 和 androidDeviceTest 都可以原样使用 @RunWith(AndroidJUnit4::class),因为 AndroidJUnit4 是一个路由器。source set 的配置见 ../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md。
何时不应使用 Robolectric
| 场景 | 适用位置 |
|---|---|
纯 Kotlin,不导入 android.* | 在 src/test/ 中使用普通 JUnit4(不使用 Robolectric)。 |
| 像素级准确的渲染、截图测试 | 模拟器/设备——RenderThread 在那里是真实的,在 Robolectric 上则不是。 |
| 无障碍服务行为 | 模拟器/设备。Compose 的 enableAccessibilityChecks 会在 Robolectric 上安装验证器,并记录警告(Build.FINGERPRINT.lowercase() == "robolectric");Robolectric 无法忠实驱动无障碍服务,因此任何测试通过都不能作为定论(b/332778271)。 |
Choreographer 保真度、接近生产环境的动画时序 | 模拟器/设备。 |
| GPU/Skia 行为 | 模拟器/设备。选择启用 @GraphicsMode(GraphicsMode.Mode.NATIVE) 也只是对真实管线的存根。 |
模式
模式:错误做法——对也应在设备上运行的测试使用 RobolectricTestRunner
// WRONG
@RunWith(RobolectricTestRunner::class)
@Config(minSdk = 23)
class LoginViewModelTest {
@Test fun loginFlow() { /* ... */ }
}
// WRONG because: this class CAN run unchanged on a device too — there are no
// Robolectric-only APIs in the body. Pinning to RobolectricTestRunner locks the
// test to the JVM and forfeits the sharedTest pattern. The same class moved to
// src/androidTest/ (or androidDeviceTest) would not run.
// RIGHT
@RunWith(AndroidJUnit4::class)
@Config(minSdk = RobolectricMinSdk) // pin via single source of truth
class LoginViewModelTest {
@Test fun loginFlow() { /* ... */ }
}
模式:错误做法——排空 looper 前就断言
// WRONG
@RunWith(AndroidJUnit4::class)
class HandlerTest {
@Test fun postedRuns() {
var fired = false
Handler(Looper.getMainLooper()).post { fired = true }
assertTrue(fired) // FAIL: runnable is queued, not run
}
}
// WRONG because: Robolectric's default LooperMode.PAUSED does not auto-run posted
// runnables. The post is queued; assertTrue runs before the queue is drained.
// RIGHT
@RunWith(AndroidJUnit4::class)
class HandlerTest {
@Test fun postedRuns() {
var fired = false
Handler(Looper.getMainLooper()).post { fired = true }
shadowOf(Looper.getMainLooper()).idle() // drain queue
assertTrue(fired)
}
}
模式:错误做法——缺少 includeAndroidResources
// WRONG
android {
testOptions {
unitTests { /* includeAndroidResources missing */ }
}
}
症状:测试启动时出现 android.content.res.Resources$NotFoundException: String resource ID #0x7f0c0001。修复方法:
// RIGHT
android {
testOptions {
unitTests {
includeAndroidResources = true // Groovy
// isIncludeAndroidResources = true // Kotlin DSL
}
}
}
模式:错误做法——在 AGP 7.2+ 上共享 sharedTest srcDir
// WRONG
android {
sourceSets {
test { java.srcDirs += 'src/sharedTest/java' }
androidTest { java.srcDirs += 'src/sharedTest/java' }
}
}
// WRONG because: AGP 7.2+ rejects sharing a single source directory across
// source sets (Robolectric issue #7432). Build fails with "Source directory
// already added to source set".
// RIGHT — AndroidX-style KMP source sets
sourceSets {
androidCommonTest { dependsOn(commonTest) }
androidDeviceTest { dependsOn(androidCommonTest) }
androidHostTest { dependsOn(androidCommonTest) }
}
模式:ParameterizedRobolectricTestRunner——适用于测试矩阵本身就是目的的场景
// androidx/compose/ui/ui-test/.../ViewVisibilityRobolectricTest.kt (paraphrased)
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(minSdk = RobolectricMinSdk)
class ViewVisibilityRobolectricTest(private val visibility: Int) {
companion object {
@JvmStatic
@Parameters(name = "visibility={0}")
fun params() = listOf(View.VISIBLE, View.INVISIBLE, View.GONE)
}
@Test fun behaves() { /* uses `visibility` */ }
}
这是唯一适合指定 RobolectricTestRunner(及其参数化子类)的场景——没有 AndroidJUnit4 参数化变体。
模式:生产代码根据 Robolectric 分支——请谨慎看待
// androidx/compose/ui/ui-test/.../RobolectricIdlingStrategy.android.kt
internal val HasRobolectricFingerprint
get() = Build.FINGERPRINT.lowercase() == "robolectric"
这种惯用写法在 androidx 内部很常见(Compose 无障碍、空闲状态、图形图层)。被测库代码 SHOULD 避免照搬,除非该分支确实仅用于测试——误判会悄无声息地改变行为。
强制规则
- MUST 从
androidx.test.ext.junit.runners导入AndroidJUnit4。MUST NOT 从androidx.test.runner导入——后者已标记为@Deprecated(docs/CORPUS.md§G.2)。 - MUST 优先使用
@RunWith(AndroidJUnit4::class),而不是@RunWith(RobolectricTestRunner::class)。仅当测试直接使用 Robolectric 专用 API(例如shadowOf(Looper.getMainLooper()).idle())或需要ParameterizedRobolectricTestRunner时,才使用后者。 - MUST 对任何单元测试会涉及
R.*/Resources/ 布局 / 主题的项目,设置testOptions.unitTests.includeAndroidResources = true。 - MUST 在断言任何依赖
Handler.post*回调的内容前,使用shadowOf(Looper.getMainLooper()).idle()(或ShadowLooper.runUiThreadTasksIncludingDelayedTasks())排空 looper。默认的LooperMode.PAUSED不会自动运行任务。 - MUST 在类级别固定
@Config(minSdk = …)(或sdk = […])——不要依赖项目默认 SDK 在 CI 中变化。 - MUST NOT 在 AGP 7.2+ 上使用旧式
srcDirs共享模式test.java.srcDirs += 'src/sharedTest/java'。迁移到androidHostTest+androidDeviceTest(KMP 源集),或使用单独的 Gradle 模块。 - MUST NOT 在不含任何
android.*导入的测试类中添加 Robolectric。类加载成本会白白增加 1-3 s。 - MUST NOT 使用 Robolectric 进行截图测试、涟漪动画、无障碍服务或
RenderThread时序测试——请使用模拟器/设备。 - PREFERRED: 在
androidHostTest模块中,将RobolectricMinSdk = 23引用自androidx/compose/ui/ui-test/src/androidHostTest/.../Constants.kt,作为最低 SDK 的唯一真实来源。 - PREFERRED: 将相关测试归入同一个类,以分摊每个类的类加载成本。避免在许多小类中零散添加
@RunWith(RobolectricTestRunner::class)。 - PREFERRED: 在提交门禁上使用单 SDK 的
@Config(sdk = [33]);在合并门禁上使用多 SDK 矩阵。
验证
- 不导入
androidx.test.runner.AndroidJUnit4(请使用androidx.test.ext.junit.runners.AndroidJUnit4)。运行grep -rn 'androidx.test.runner.AndroidJUnit4' src/test src/androidTest后应无输出。 - 模块的
build.gradle(.kts)中已设置testOptions.unitTests.includeAndroidResources = true。 - 未在实际上不使用 Robolectric 专用 API 的测试类上添加
@RunWith(RobolectricTestRunner::class)。 - 每个向
Handler发送消息或触发Looper工作的测试,都在断言前调用shadowOf(Looper.getMainLooper()).idle()(或runUiThreadTasksIncludingDelayedTasks)。 - 每个 Robolectric 测试类都声明了
@Config(minSdk = …)或@Config(sdk = […])。 - 源集配置使用
androidHostTest+androidDeviceTest(或共享模块),而非旧式的srcDirs += sharedTest。 - Robolectric 下不运行截图、波纹或无障碍服务测试——这些测试应放在
src/androidTest/或androidDeviceTest中。 -
./gradlew :module:test通过;对于androidCommonTest中的测试,./gradlew :module:connectedDebugAndroidTest也通过。
参考资料
- Robolectric — 入门指南:http://robolectric.org/getting-started/
- Robolectric — 配置:http://robolectric.org/configuring/
- Robolectric — sharedTest 博客文章:http://robolectric.org/blog/2021/10/06/sharedTest/
- Android Developers — 构建本地单元测试:https://developer.android.com/training/testing/local-tests
- Android Developers — Robolectric:https://developer.android.com/training/testing/local-tests/robolectric
- Robolectric GitHub issue #7432 — AGP 7.2 sharedTest 故障:https://github.com/robolectric/robolectric/issues/7432
- 研究资料:
tasks/research/R7-robolectric.md。 docs/CORPUS.md§G.1 / §G.6 — Gradle 坐标和 runner 选择。androidx/compose/ui/ui-test/src/androidHostTest/.../Constants.kt—RobolectricMinSdk = 23下限。androidx/compose/ui/ui-test/.../RobolectricComposeTest.kt— 跨运行时测试的规范示例:@RunWith(AndroidJUnit4::class) @Config(minSdk = RobolectricMinSdk)。androidx/compose/ui/ui-test/.../PrefetchNotHangingMainThreadTest.kt— 使用@RunWith(RobolectricTestRunner::class),并显式调用shadowOf(Looper.getMainLooper()).idle()。androidx/compose/ui/ui-test/.../ViewVisibilityRobolectricTest.kt—ParameterizedRobolectricTestRunner示例。androidx/compose/ui/ui-test/.../RobolectricIdlingStrategy.android.kt—Build.FINGERPRINT.lowercase() == "robolectric"检测惯用写法。androidx/compose/ui/ui/.../MediaQueryIntegrationTest.kt— 在@RunWith(AndroidJUnit4::class)测试中使用ShadowPackageManager/ShadowInputManager。- 同类资料:
../../coroutines/testing-coroutines-with-runtest/SKILL.md—runTest+MainDispatcherRule(独立于 Robolectric)。 - 同类资料:
../../coroutines/testing-flows-with-turbine/SKILL.md— Flow 断言;Turbine 在 Robolectric 下无需修改即可使用。 - 同类资料:
../../runner/configuring-junit4-on-android/SKILL.md— Robolectric 周边的 JUnit4 配置。 - 同类资料:
../../mocking/mocking-with-mockito/SKILL.md— 在 Robolectric 下模拟 Android 类型。 - 同类资料:
../../mocking/mocking-with-mockk/SKILL.md— 针对 fingerprint 分支使用mockkStatic(Build::class)。 - 跨集合资料:
../../../fundamentals/strategies/applying-testing-strategies/SKILL.md— 在 Google 的三级测试规模划分中,Robolectric 属于“中型”测试。 - 跨集合资料:
../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md—src/test/、src/androidTest/与androidHostTest/androidDeviceTest。 - 跨集合资料:
../../../compose/synchronization/controlling-the-test-clock/SKILL.md— Compose 的MainTestClock与ShadowLooper相互独立。 - 跨集合资料:
../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md— 同一测试源需要在设备上运行时的做法。