第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 实现流程:三步构建夹具隔离
- 定义自定义 Rule 类:实现
TestRule接口,通常命名为FixtureCleanRule或ComposeIsolationRule。 - 重写 apply 方法:创建匿名或具名
Statement子类,在evaluate()中编写前置逻辑(初始化/重置)、调用base.evaluate()、后置逻辑(清理)。 - 注册 Rule:在测试类中使用
@get:Rule注解,将其作为ComposeTestRule或常规TestRule注册。
流程对照表
| 阶段 | 典型操作 | 异常处理 |
|---|---|---|
| 前置(before) | 重置单例、注入 Mock 依赖、设置本地化、清空协程作用域 | 捕获异常并回滚已改变的状态 |
| 测试体(evaluate) | 执行 @Test 方法内的 Compose 交互和断言 |
不干预,让异常传播 |
| 后置(after) | 销毁资源、恢复默认配置、关闭数据库连接 | 使用 try/finally 保证即使测试失败也能执行 |
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 专注管理那些 容易泄漏 的状态(如全局静态变量、单例、协程作用域),可以使夹具隔离成本最低、收益最高。