第4章 自定义 TestRule 实现夹具隔离

4.1 原理:从“共享污染”到“独立沙箱”

在 Compose UI 测试中,默认的测试执行模型不会为每个测试方法自动清除之前的状态。如果多个测试共用同一个 Compose 节点树或全局单例(例如 ViewModel、SharedPreferences、主题配置),就会出现 夹具污染(Fixture Pollution)——前一个测试留下的状态改变了后一个测试的初始条件,导致不可重复的失败。

TestRule 是 JUnit 4 提供的扩展点,允许在测试执行前后插入自定义逻辑。在 Compose 环境下,自定义 TestRule 的核心作用是在每个测试方法开始前重置夹具、设置前置条件,并在测试结束后清理资源,从而为每个测试创建独立的“沙箱”。

关键接口方法:

// JUnit 4 TestRule 原始定义
public interface TestRule {
    Statement apply(Statement base, Description description);
}
  • apply 接收一个 Statement base(待执行的测试体)和一个 Description(当前测试描述)。
  • 你返回一个自定义 Statement,其 evaluate() 方法可以插入前置/后置操作。
  • 框架运行时调用 evaluate(),完成隔离。
隔离原则:尽可能在 before 中重置状态(如清空 ViewModel、重新初始化 DataStore),而不依赖 beforeEach 或其他全局生命周期回调。

4.2 实现流程:三步构建夹具隔离

  1. 定义自定义 Rule 类:实现 TestRule 接口,通常命名为 FixtureCleanRule 或 ComposeIsolationRule。
  2. 重写 apply 方法:创建匿名或具名 Statement 子类,在 evaluate() 中编写前置逻辑(初始化/重置)、调用 base.evaluate()、后置逻辑(清理)。
  3. 注册 Rule:在测试类中使用 @get:Rule 注解,将其作为 ComposeTestRule 或常规 TestRule 注册。

流程对照表

阶段 典型操作 异常处理
前置(before) 重置单例、注入 Mock 依赖、设置本地化、清空协程作用域 捕获异常并回滚已改变的状态
测试体(evaluate) 执行 @Test 方法内的 Compose 交互和断言 不干预,让异常传播
后置(after) 销毁资源、恢复默认配置、关闭数据库连接 使用 try/finally 保证即使测试失败也能执行
自定义 TestRule 执行流程 @Test 方法触发 apply(base, description) evaluate() 执行 try { before() ; base.evaluate() } finally { after() } 前置重置 → 测试正文 → 后置清理 finally 保证 after 总执行 测试结果(通过/失败)

4.3 代码实现:Compose 夹具隔离 Rule 示例

以下示例实现一个 ComposeFixtureRule,每次测试前清空内容供给(ContentProvider)、重置主题状态,测试后释放资源。

class ComposeFixtureRule : TestRule {
    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            override fun evaluate() {
                // ======= 前置操作 =======
                // 1. 清空全局内容供给
                ComposeTestRule.cleanupState()

                // 2. 重置主题模式
                ThemeStore.setTheme(ThemeMode.SYSTEM)

                // 3. 注入 Mock 依赖(若使用依赖注入框架)
                // MockPlatformProvider.install()

                // 4. 清空共享协程作用域中的测试协程
                testCoroutineScope.cleanup()

                // ======= 执行测试 =======
                try {
                    base.evaluate()
                } finally {
                    // ======= 后置操作 =======
                    // 1. 销毁创建的节点树(Compose 框架自动处理,但仍可手动清空缓存)
                    // 2. 恢复原始协程作用域
                    // 3. 关闭打开的文件/H2数据库等
                    testCoroutineScope.reset()
                }
            }
        }
    }
}

// 在测试类中注册
class MyComposeTest {
    @get:Rule
    val fixtureRule = ComposeFixtureRule()

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testName() {
        // fixture 已经由 fixtureRule 清空
        composeTestRule.setContent {
            SampleScreen()
        }
        // ...
    }
}
注意:createComposeRule() 本身也是一个 TestRule,多个 Rule 的执行顺序由 @Rule 注解的顺序(字段声明顺序)决定。隔离 Rule 应放在 composeTestRule 之前,确保前置操作在 Compose 环境构建前生效。

4.4 调试技巧

  • 打印调用栈:在 evaluate() 开始处添加 Thread.currentThread().stackTrace 日志,确认 Rule 被正确包装。
  • 使用 description:Description 包含测试类名和方法名,可以在前置/后置中针对性处理(如跳过某些测试)。
  • 异常透传:不要吞没 base.evaluate() 抛出的异常;如需记录后置清理异常,使用 addSuppressed。
  • IDE 断点:在 evaluate() 内前置/后置处设置断点,观察状态变化。

4.5 常见错误与避坑

错误 后果 修正方法
遗漏 super.apply 或未返回 Statement 测试无法执行或抛出空指针 始终返回自定义 Statement 对象
前置操作在 finally 块之外 测试失败时不执行后置清理 使用 try { base.evaluate() } finally { after() }
在 apply 中直接修改全局状态 影响其他 Rule 或测试顺序 只在 evaluate() 中操作,不要在构造时修改
后置操作抛出异常(如空指针) 覆盖原始测试异常,导致误导 在后置中捕获预期异常并记录日志,不重新抛出
Rule 注册为 @Rule 而非 @get:Rule Kotlin 属性注解错误导致 Rule 不生效 使用 @get:Rule 注解属性

4.6 性能与稳定性优化

  • 懒初始化:将开销大的资源(如数据库连接、Mock 框架)在首次前置操作时创建,而非在 Rule 构造时。
  • 复用可共享状态:如果多个测试需要相同初始状态(如空列表),在 after 中把状态重置为“干净初始值”,而不是每次都重新创建所有对象。
  • 避免 I/O 操作:前置/后置中应避免读写文件或网络请求,尽量使用内存对象模拟。
  • 批量清理:使用 addSuppressed 或 Closeable 接口统一管理多个需要清理的资源,减少重复代码。
  • 监控执行时间:在 Rule 中添加计时逻辑,当单个前置/后置耗时超过预期(如 >50ms)时发出警告,防止测试套件膨胀。

最终策略:将每个测试的“起点”强制为同一种干净状态,而不是试图回滚所有可能的变化。通过自定义 TestRule 专注管理那些 容易泄漏 的状态(如全局静态变量、单例、协程作用域),可以使夹具隔离成本最低、收益最高。