JUnit 5 在 Android 测试中的配置与使用
JUnit 5 在 Android 测试中的配置与使用
Android 测试长期被 JUnit 4 统治。从 2018 年 Google 在 AndroidX Test 库中引入 AndroidJUnitRunner 开始,这个基于 JUnit 4 的运行器就成了标准配置。但 JUnit 5 早在 2017 年就已经发布,带来了扩展模型、参数化测试、嵌套测试、动态测试等一系列改进,这些特性在 JVM 后端开发中早已普及,Android 开发者却迟迟用不上。问题不在于 JUnit 5 本身,而在于 Android 生态的特殊性:Dalvik/ART 虚拟机、Gradle 构建系统的 Android Plugin、以及 Google 对测试基础设施的保守演进策略。
直到 2021 年,Marcel Schnelle 发布的 junit5-gradle-test-experiments 实验项目,以及后续成熟的 android-junit5 插件,才让 JUnit 5 在 Android 上真正可用。这篇文章基于 android-junit5 插件 1.10.0.0 版本、Android Gradle Plugin 8.2.0 和 JUnit 5.10.0 的实际配置经验,记录从集成到日常使用的完整路径,包括那些官方文档不会告诉你的坑。
为什么不是 JUnit 4 的 Rule 模型
JUnit 4 的核心扩展机制是 @Rule 和 @ClassRule。写过 Android 测试的人都熟悉 ActivityTestRule、GrantPermissionRule、ServiceTestRule 这些 Google 提供的规则。Rule 模型的问题在于它的执行时机和组合方式过于僵硬。一个 Rule 本质上是对 Statement 的包装,多个 Rule 嵌套时执行顺序依赖于声明顺序和内部实现,调试起来像拆俄罗斯套娃。
更深层的问题是,Rule 无法很好地介入测试生命周期的各个阶段。比如你想在 @BeforeEach 之后、@Test 之前插入一段逻辑,在 JUnit 4 中需要 hack TestWatcher 或者自定义 Runner,而自定义 Runner 又意味着放弃 AndroidJUnitRunner 提供的所有功能。Google 在 AndroidX Test 中提供的 AndroidJUnit4 类继承自 AndroidJUnit4ClassRunner,这个 Runner 已经相当重量级,再叠加自定义 Runner 几乎必然冲突。
JUnit 5 的 Extension API 彻底改变了这个局面。BeforeEachCallback、AfterEachCallback、BeforeAllCallback、AfterAllCallback、ParameterResolver、ExecutionCondition 等接口提供了细粒度的扩展点。每个扩展只负责一件事,通过 @ExtendWith 或 @RegisterExtension 注册,执行顺序可以通过 @Order 精确控制。这种设计让测试基础设施的模块化成为可能,而不是把各种横切关注点塞进一个臃肿的 Rule 里。
android-junit5 插件的定位与集成
直接说结论:在 Android 项目上使用 JUnit 5,目前最成熟的选择是 Marcel Schnelle 维护的 android-junit5 Gradle 插件,项目地址在 GitHub 的 mannodermaus/android-junit5。这不是 Google 官方支持的项目,但它是社区事实标准,目前维护活跃,最新版本 1.10.0.0 支持 AGP 8.x 和 Gradle 8.x。
插件的核心作用是在 Android Gradle Plugin 的测试任务体系中桥接 JUnit Platform。Android 的测试执行不像纯 Java 项目那样直接调用 Gradle 的 test 任务,而是有 testDebugUnitTest、testReleaseUnitTest 等变体任务,以及连接设备的 connectedCheck 系列任务。android-junit5 插件为这些任务配置 JUnit Platform 运行器,处理依赖注入,并解决 JUnit 5 与 Android 构建系统的兼容性问题。
集成步骤并不复杂,但有几个关键点容易踩坑。首先在项目级 build.gradle.kts 的 plugins 块中声明:
plugins {
id("com.android.application") version "8.2.0" apply false
id("de.mannodermaus.android-junit5") version "1.10.0.0" apply false
}然后在模块级 build.gradle.kts 中应用插件并配置依赖:
plugins {
id("com.android.application")
id("de.mannodermaus.android-junit5")
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0")
// 如果需要同时运行 JUnit 4 遗留测试
testImplementation("junit:junit:4.13.2")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.10.0")
}这里第一个坑出现了:JUnit 5 的模块化设计意味着 junit-jupiter 被拆成了 api、engine、params 等多个 artifact。很多人只加了 api 和 engine,发现参数化测试 @ParameterizedTest 找不到,就是因为漏了 junit-jupiter-params。这个设计在 JVM 后端项目中是常识,但 Android 开发者习惯了 JUnit 4 的单体依赖,容易忽略。
第二个坑是 Vintage Engine 的配置。Android 项目通常有大量遗留的 JUnit 4 测试,尤其是依赖 AndroidX Test 的仪器测试。JUnit 5 的 Vintage Engine 允许在 JUnit Platform 上运行 JUnit 4 测试,实现混合执行。但这里有个限制:仪器测试(instrumentation tests,即 androidTest 目录下的测试)目前仍然必须使用 JUnit 4,因为 AndroidJUnitRunner 本身基于 JUnit 4。android-junit5 插件只支持单元测试(test 目录)使用 JUnit 5,仪器测试暂时无法迁移。这是 Google 基础设施的限制,不是插件能解决的。
配置细节:Gradle 变体与过滤
Android 的多构建变体(build variant)机制让测试配置比纯 Java 项目复杂得多。android-junit5 插件为每个变体生成对应的测试任务,比如 testDebugUnitTest、testReleaseUnitTest、testStagingUnitTest 等。默认情况下所有变体都会执行,但通常我们只想在 debug 变体上跑测试。
在 build.gradle.kts 中可以通过 junitPlatform 配置块统一设置:
android {
// ...
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
// 或者更精细地控制
junitPlatform {
configurationParameter("junit.jupiter.testinstance.lifecycle.default", "per_class")
}configurationParameter 可以设置 JUnit 5 的全局配置参数。比如 junit.jupiter.testinstance.lifecycle.default 控制测试实例生命周期,默认是 per_method(每个测试方法新建实例),改为 per_class 可以减少实例化开销,但要求测试类无状态或线程安全。这个选项在 JUnit 4 中对应 @TestInstance 注解,但全局配置更方便。
测试过滤是另一个实用场景。JUnit 5 支持基于标签(tag)的过滤,通过 @Tag("slow")、@Tag("integration") 等注解标记测试,然后在 Gradle 命令中过滤:
./gradlew testDebugUnitTest -Pjunit.jupiter.tags=fast
./gradlew testDebugUnitTest -Pjunit.jupiter.excludeTags=slow注意这里的系统属性前缀是 junit.jupiter,不是 junit.platform。android-junit5 插件会自动将这些 Gradle 属性传递给 JUnit Platform,但属性名必须正确。我曾经把 excludeTags 写成 excludedTags,导致过滤失效,花了半小时在源码里找原因。
参数化测试:从理论到 Android 实践
@ParameterizedTest 是我迁移到 JUnit 5 的最大动机之一。JUnit 4 的参数化测试通过 @RunWith(Parameterized.class) 实现,需要定义静态数据方法、构造函数参数映射,样板代码极其繁琐。JUnit 5 的参数源(source)机制则灵活得多。
最基础的是 @ValueSource:
@ParameterizedTest
@ValueSource(strings = ["hello", "world", ""])
fun `validates string input`(input: String) {
assertTrue(input.isNotBlank() }但 Android 单元测试中更实用的是 @MethodSource 和 @CsvSource。比如测试一个 URL 解析工具:
@ParameterizedTest
@MethodSource("provideUrlCases")
fun `parses various URL formats`(url: String, expectedHost: String, expectedPath: String) {
val result = UrlParser.parse(url)
assertEquals(expectedHost, result.host)
assertEquals(expectedPath, result.path)
}
companion object {
@JvmStatic
fun provideUrlCases(): Stream<Arguments> = Stream.of(
Arguments.of("https://example.com/path", "example.com", "/path"),
Arguments.of("https://example.com:8080/api/v1", "example.com", "/api/v1"),
Arguments.of("http://localhost", "localhost", "/")
)
}@MethodSource 要求工厂方法返回 Stream<Arguments> 或 Iterable/Iterator。在 Kotlin 中需要注意 @JvmStatic 注解,因为 JUnit 5 通过反射调用时默认找 Java 静态方法。Companion object 的成员不加 @JvmStatic 会被编译为实例方法,导致参数源找不到。这个错误信息很隐晦,通常是 Could not find factory method [provideUrlCases],不会提示是静态方法问题。
@CsvSource 对简单表格数据更友好:
@ParameterizedTest
@CsvSource(
"hello, 5",
"'', 0",
"kotlin, 6"
)
fun `calculates string length`(input: String, expectedLength: Int) {
assertEquals(expectedLength, input.length)
}注意空字符串需要用引号包裹,否则 CSV 解析会跳过。这个细节在测试边界条件时很容易忽略。
JUnit 5.10.0 还引入了 @FieldSource 和新的 @ArgumentSource 自定义扩展,但 android-junit5 插件 1.10.0.0 基于的 JUnit 版本可能需要确认支持情况。我目前没在生产环境用 @FieldSource,因为 @MethodSource 已经覆盖大部分场景。
扩展机制:写一个 Android 专用的扩展
理论上的优势需要在实践中验证。我写过的一个实际扩展是 MainDispatcherExtension,用于解决 Kotlin Coroutines 在单元测试中的调度器替换问题。
Android 开发中大量使用 Dispatchers.Main,但在 JVM 单元测试环境中没有 Android 主线程。传统做法是依赖 kotlinx-coroutines-test 的 Dispatchers.setMain 和 Dispatchers.resetMain,每个测试类重复写 @Before 和 @After。JUnit 5 的扩展可以把这个逻辑集中化:
class MainDispatcherExtension : BeforeEachCallback, AfterEachCallback {
private val testDispatcher = StandardTestDispatcher()
override fun beforeEach(context: ExtensionContext) {
Dispatchers.setMain(testDispatcher)
}
override fun afterEach(context: ExtensionContext) {
Dispatchers.resetMain()
}
}使用方式:
@ExtendWith(MainDispatcherExtension::class)
class MyViewModelTest {
// 所有测试方法自动在 testDispatcher 上执行
}这比 JUnit 4 的 Rule 干净在哪里?第一,MainDispatcherExtension 是一个纯 Kotlin 类,不继承任何框架基类,测试逻辑与框架解耦。第二,它可以与其他扩展组合,比如同时注册 MainDispatcherExtension 和 MockKExtension(如果使用 MockK),执行顺序通过 @Order 控制。第三,扩展可以访问 ExtensionContext,获取测试类、方法、标签等元数据,实现条件化逻辑。
但这里有个 Android 特有的坑:StandardTestDispatcher 需要手动推进时间。如果测试代码使用了 delay,必须调用 testDispatcher.scheduler.advanceUntilIdle() 或 runCurrent(),否则测试会挂起。这个行为与 UnconfinedTestDispatcher 不同,后者会立即执行所有挂起任务。我的扩展默认使用 StandardTestDispatcher 是因为它能更精确控制执行时机,但需要在文档中明确说明。
另一个实际扩展是 TemporaryFileExtension,用于管理测试用的临时文件。Android 单元测试运行在 JVM 上,有正常的文件系统访问,但 java.nio.file.Files.createTempDirectory 创建的临时目录不会自动清理。JUnit 5 内置的 TempDir 扩展已经很好用了,但如果你想自定义清理策略(比如保留失败测试的目录用于调试),可以写自己的扩展。
与 MockK 的集成:Android 友好的 Mock 框架
Mockito 在 Android 单元测试中历史悠久,但 Kotlin 的空安全、顶层函数、扩展函数等特性让 Mockito 的使用颇为别扭。MockK(https://mockk.io)是专为 Kotlin 设计的 mock 框架,支持 mock 普通类、object、静态方法、协程等,在 Android 社区接受度很高。
MockK 从 1.13.x 版本开始提供 JUnit 5 扩展,artifact 是 io.mockk:mockk-jvm 和 io.mockk:mockk-agent-jvm。配置如下:
dependencies {
testImplementation("io.mockk:mockk-jvm:1.13.8")
testImplementation("io.mockk:mockk-agent-jvm:1.13.8")
}MockKExtension 的主要功能是自动清理 mock 状态。在 JUnit 4 中,通常需要 @After 调用 unmockkAll(),忘记的话可能导致 mock 状态泄漏到后续测试。JUnit 5 扩展自动处理这个清理:
@ExtendWith(MockKExtension::class)
class UserRepositoryTest {
@MockK
lateinit var apiService: ApiService
@InjectMockKs
lateinit var repository: UserRepository
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
}
@Test
fun `fetches users from api`() = runTest {
coEvery { apiService.getUsers() } returns listOf(User("1", "Alice"))
val result = repository.getUsers()
assertEquals(1, result.size)
coVerify { apiService.getUsers() }
}
}注意 runTest 是 kotlinx-coroutines-test 提供的测试构建器,替代了旧的 runBlockingTest。它需要 TestDispatcher,这就是为什么前面的 MainDispatcherExtension 很重要。如果 Dispatchers.Main 没有正确替换,runTest 内部的 launch(Dispatchers.Main) 会报错。
MockK 在 Android 上的主要限制是 inline mock 需要 Java agent,而某些 CI 环境(尤其是 Docker 容器)对 agent 有限制。MockK 提供了 mockk-agent-api 的替代方案,但功能会受限。这个不是 JUnit 5 特有的问题,但在配置测试环境时需要留意。
性能测试:JUnit 5 与基准测试的边界
Android 官方的性能测试工具是 Macrobenchmark 和 Microbenchmark,都属于仪器测试,运行在真机或模拟器上。JUnit 5 目前无法用于这些场景,前面已经提到仪器测试的 JUnit 4 限制。
但在单元测试层面,JUnit 5 的 @RepeatedTest 和 @Timeout 可以用于简单的性能回归检测。比如:
@RepeatedTest(100)
@Timeout(value = 100, unit = TimeUnit.MILLISECONDS)
fun `json parsing should be fast`() {
val json = loadResource("large_response.json")
JsonParser.parse(json)
}这个测试重复执行 100 次,每次超时 100 毫秒。它不能替代专业的基准测试(没有预热、没有统计显著性检验、受 JVM 编译和 GC 影响),但可以作为 CI 中的快速回归检测。我曾经在 CI 中配置这个测试来捕获 JSON 解析库的意外性能退化,确实发现过一次引入反射导致的 10 倍 slowdown。
更严肃的微基准应该使用 JMH(Java Microbenchmark Harness),但 JMH 与 Android 项目的集成比较麻烦,因为 JMH 需要独立的 main 函数入口,而 Android 库模块没有标准 main。我的做法是把性能敏感的算法抽到纯 Java/Kotlin 模块,用 JMH 单独测试,Android 模块只负责集成测试。
迁移策略:从 JUnit 4 渐进过渡
完全重写测试套件不现实。android-junit5 插件支持通过 Vintage Engine 混合运行 JUnit 4 和 JUnit 5 测试,这让渐进迁移成为可能。
我的实际策略是:新测试用 JUnit 5 写,旧测试逐步重构。重构优先级是:
参数化测试最先迁移,收益最明显。JUnit 4 的 @RunWith(Parameterized.class) 需要把测试类改成抽象模板模式,与 Spring/Android 的其他 Runner 冲突时几乎无法解决。JUnit 5 的 @ParameterizedTest 是普通测试方法,没有 Runner 限制。
依赖复杂 Rule 的测试其次。比如自定义的 RxJavaSchedulerRule、CoroutinesMainRule 等,改写成 JUnit 5 扩展后更清晰,且可以复用到多个测试类。
简单的 @Test 方法最后迁移,除非需要用到 JUnit 5 的新特性。
一个具体的重构例子,把 JUnit 4 的 Coroutines Rule 改成扩展:
// JUnit 4 版本
class MyTest {
@get:Rule
val coroutineRule = MainCoroutineRule()
@Test
fun test() = coroutineRule.runBlockingTest {
// ...
}
}
// JUnit 5 版本
@ExtendWith(MainDispatcherExtension::class)
class MyTest {
@Test
fun test() = runTest {
// ...
}
}注意 runBlockingTest 是旧 API,runTest 是新 API,迁移时一并更新。
混合运行时的注意事项:Vintage Engine 会识别 JUnit 4 的 @Test、@Rule、@RunWith 等注解,但 JUnit 5 的扩展不会作用于 JUnit 4 测试。也就是说,@ExtendWith(MainDispatcherExtension::class) 对 JUnit 4 测试无效,需要保持旧的 Rule 配置直到完全迁移。
CI/CD 集成:GitHub Actions 与 Firebase Test Lab
单元测试在 CI 中运行通常没有额外配置,但需要注意 Gradle 的并行执行设置。JUnit 5 支持并行测试执行,通过 junit-platform.properties 配置:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent把这个文件放在 src/test/resources 下。但在 Android 项目中,我发现并行执行有时会导致 MockK 的静态 mock 冲突,尤其是多个测试类同时 mock 同一个 object。目前我的做法是按类隔离并行,方法级串行:
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.mode.classes.default=concurrentFirebase Test Lab 跑仪器测试时,JUnit 5 仍然不可用。这是 Google 云服务的限制,不是本地构建的问题。如果未来 Google 发布基于 JUnit 5 的 Android 测试运行器,这个限制会解除,但目前没有明确时间表。
已知限制与未解决问题
android-junit5 插件虽然成熟,但有几个限制需要了解。
第一,仪器测试(androidTest)不支持。这是 AndroidJUnitRunner 的架构限制,不是插件能绕过的。Google 在 2023 年的 Android Dev Summit 上提到过测试基础设施的现代化,但没有公布 JUnit 5 支持的具体计划。如果需要仪器测试用 JUnit 5,目前唯一的替代方案是 Robolectric 的仪器测试模拟,但 Robolectric 本身也有兼容性边界。
第二,Jacoco 覆盖率集成需要额外配置。android-junit5 插件提供了 de.mannodermaus.android-junit5 的 Jacoco 扩展,但版本匹配很重要。AGP 8.x 改变了覆盖率数据的输出路径,旧版 Jacoco 配置会找不到执行数据。我的 working configuration:
android {
buildTypes {
debug {
enableUnitTestCoverage = true
}
}
}
dependencies {
testImplementation("de.mannodermaus.junit5:android-test-core:1.4.0")
testRuntimeOnly("de.mannodermaus.junit5:android-test-runner:1.4.0")
}注意这里的 android-test-core 和 android-test-runner 是 junit5 插件的辅助库,用于在 Android 环境(包括 Robolectric)中运行测试,不是 JUnit 5 本身。
第三,Kotlin 的 all-open 插件与 JUnit 5 的 per_class 生命周期有冲突。如果测试类被 all-open 插件打开(为了 mock 或其他原因),@TestInstance(PER_CLASS) 的静态语义可能被破坏。这个很边缘,但我在一个使用了 all-open 的 legacy 项目中遇到过。
最后
JUnit 5 在 Android 单元测试中的价值,不在于某个 killer feature,而在于扩展模型的长期可维护性。参数化测试让数据驱动测试的编写成本从"写一堆样板"降到"加一个注解";Extension API 让横切关注点的抽象从"继承重量级 Runner"变成"实现轻量级接口";与 Kotlin 协程、MockK 等现代 Android 工具链的配合,也比 JUnit 4 的 Rule 模型更自然。
但迁移成本是真实的。仪器测试的 JUnit 4 锁定意味着短期内必须维护两套测试范式,团队需要学习成本,CI 配置需要调整,旧测试的重构优先级需要权衡。我个人觉得,对于新项目或者单元测试覆盖较高的项目,直接上 JUnit 5 是值得的;对于仪器测试为主、或者测试债务较重的 legacy 项目,可以先保持 JUnit 4,在新增单元测试时逐步引入。
android-junit5 插件目前是免费开源的(Apache 2.0 协议),Marcel Schnelle 的持续维护让这个项目保持了与最新 AGP 和 Gradle 版本的兼容性。如果 Google 未来在官方 Android Gradle Plugin 中内置 JUnit 5 支持,这个插件的历史使命就完成了,但在此之前,它是 Android 开发者用上现代测试基础设施的最可靠路径。