JUnit 5 在 Android 测试中的配置与使用

JUnit 5 在 Android 测试中的配置与使用

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 测试的人都熟悉 ActivityTestRuleGrantPermissionRuleServiceTestRule 这些 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 彻底改变了这个局面。BeforeEachCallbackAfterEachCallbackBeforeAllCallbackAfterAllCallbackParameterResolverExecutionCondition 等接口提供了细粒度的扩展点。每个扩展只负责一件事,通过 @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 任务,而是有 testDebugUnitTesttestReleaseUnitTest 等变体任务,以及连接设备的 connectedCheck 系列任务。android-junit5 插件为这些任务配置 JUnit Platform 运行器,处理依赖注入,并解决 JUnit 5 与 Android 构建系统的兼容性问题。


集成步骤并不复杂,但有几个关键点容易踩坑。首先在项目级 build.gradle.ktsplugins 块中声明:


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 被拆成了 apiengineparams 等多个 artifact。很多人只加了 apiengine,发现参数化测试 @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 插件为每个变体生成对应的测试任务,比如 testDebugUnitTesttestReleaseUnitTesttestStagingUnitTest 等。默认情况下所有变体都会执行,但通常我们只想在 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-testDispatchers.setMainDispatchers.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 类,不继承任何框架基类,测试逻辑与框架解耦。第二,它可以与其他扩展组合,比如同时注册 MainDispatcherExtensionMockKExtension(如果使用 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-jvmio.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() }
    }
}

注意 runTestkotlinx-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 的测试其次。比如自定义的 RxJavaSchedulerRuleCoroutinesMainRule 等,改写成 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=concurrent

Firebase 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-coreandroid-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 开发者用上现代测试基础设施的最可靠路径。

Android 的缓存目录选择:cacheDir vs filesDir vs externalCacheDir 2026-06-22
RenderThread 的异步渲染,UI 线程真的减负了吗 2026-06-23

评论区