Step 3: Statement 封装与执行链 —— 装饰器模式在 TestRule 中的落地
3.1 核心机制:Statement 是执行链的唯一载体
JUnit 的 Statement 是一个抽象类,仅包含一个核心方法 evaluate()。整个测试的执行路径,从 @Before、@Test 到 @After,最终都被折叠成一个嵌套的 Statement 链。 TestRule 通过装饰器模式,将自身的逻辑包装在链条的某个层次中,从而控制 evaluate() 的调用时机。
关键认知: 框架内部并不会直接调用
@Test 方法,而是将其封装为最内层的 Statement。所有 TestRule 就像洋葱皮一样,一层层包裹这个核心,最终只调用最外层的 evaluate()。3.2 装饰器模式的结构抽象
下面这张 SVG 图展示了 TestRule 如何利用装饰器模式构建执行链。核心节点包含:原始测试方法、基础 Statement、多个装饰器 Rule、以及最终的调用入口。
3.3 显式调用示例:Statement.evaluate()
下面的代码演示了 JUnit 5 框架内部典型地如何将 @Test 方法包装成 Statement,并让用户自定义的 TestRule 有机会包裹它。
// 框架内部简化实现:runLeaf
protected void runLeaf(Statement statement, Description description, RunNotifier notifier) {
// statement 此时已是最内层(真实测试方法)
// 但在此之前,框架已经将所有 TestRule 包裹成外层装饰器
for (TestRule each : getTestRules(description)) {
statement = each.apply(statement, description);
}
// 现在 statement 是最外层的装饰器链
statement.evaluate(); // 触发整个执行链
}
用户自定义 TestRule 时,必须实现 apply(Statement base, Description description) 方法,并返回一个新的 Statement。这个新 Statement 在 evaluate() 中通过调用 base.evaluate() 将执行传递到下一层。
实现模板: 下面的代码展示了一个标准的装饰器式
TestRule,它在测试方法执行前后分别输出日志。import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
public class LoggingRule implements TestRule {
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
// 前置逻辑 (Before 增强)
System.out.println("[开始测试] " + description.getMethodName());
try {
base.evaluate(); // 核心:调用下游 Statement
} finally {
// 后置逻辑 (After 增强)
System.out.println("[结束测试] " + description.getMethodName());
}
}
};
}
}
3.4 装饰器模式的四个角色对比
| 角色 | 对应 JUnit 元素 | 职责 |
|---|---|---|
| Component(抽象组件) | Statement 抽象类 | 定义 evaluate() 契约 |
| ConcreteComponent(具体组件) | 框架内部生成的 BaseStatement 指向 @Test | 执行真实的测试逻辑 |
| Decorator(抽象装饰器) | TestRule 接口 | 接收 Statement 参数,返回包装后的新 Statement |
| ConcreteDecorator(具体装饰器) | 用户实现的 LoggingRule / Timeout 等 | 在 base.evaluate() 前后添加横切逻辑 |
3.5 实现步骤:手动构建测试执行链
- 创建基础组件: 定义一个
Statement实例,其evaluate()方法直接调用MethodInvoker.invoke()执行@Test注解的方法。 - 收集装饰器: 通过反射或注解获取当前测试类上的所有
@Rule/@ClassRule字段列表。 - 层层包裹: 按规则顺序(通常
@Rule字段声明顺序)对Statement进行装饰:
for (TestRule rule : rules) { statement = rule.apply(statement, description); } - 触发链条: 调用最外层
statement.evaluate(),执行链自外向内逐层传递,最终到达@Test方法。 - 异常传递: 任何一层的
evaluate()抛出异常,都会沿着调用栈向上传播,直到被最外层的RunNotifier捕获。
3.6 配置细节:@Rule 字段的注入时机
JUnit 4 的 BlockJUnit4ClassRunner 在 withRules() 方法中完成装饰链的组装。框架会先通过 getTestRules() 方法获取所有 TestRule 实例,然后按照以下顺序应用:
- 字段顺序: 声明在类中靠前的
@Rule字段,其apply()方法先被调用,成为更外层的包装。 - 继承规则: 子类的
@Rule字段会在父类规则之后被执行包裹,但位于@Test之前。 - 固定规则:
@ClassRule作用在类级别,包裹整个测试类;@Rule作用在实例级别,包裹每个测试方法。
注意顺序陷阱: 如果多个
TestRule 都需要对测试方法进行资源初始化/清理,错误的包裹顺序可能导致资源泄漏。例如,TemporaryFolder 应该在最外层包裹,才能确保在目录删除前其他规则已完成日志刷新。3.7 调试方法:跟踪 evaluate() 调用栈
当执行链出现异常或顺序不符合预期时,通过打印调用栈可以直观地看到装饰器嵌套层次:
// 在每个装饰器 evaluate 入口加入:
new RuntimeException("=== 装饰器跟踪点 ===").printStackTrace();
// 输出示例(简化):
java.lang.RuntimeException: === 装饰器跟踪点 ===
at LoggingRule$1.evaluate(LoggingRule.java:12)
at TimeoutRule$1.evaluate(TimeoutRule.java:8)
at BaseStatement.evaluate(BaseStatement.java:4)
at org.junit.runners.model.Runner.run(Runner.java:45)
堆栈阅读要点: 从栈顶到栈底,越往上越外层;最底部的 Runner.run() 是框架的调用入口。如果发现某个装饰器的 evaluate() 始终不出现,说明该规则未被正确注入或 apply() 返回了错误的 Statement。
3.8 常见错误及优化
| 错误场景 | 现象 | 解决方案 |
|---|---|---|
忘记调用 base.evaluate() | 测试方法永远不会执行,测试直接通过(无断言失败) | 确保装饰器 evaluate() 内部显式调用了 base.evaluate() |
多次调用 base.evaluate() | 测试方法被执行多次,可能产生资源竞争或状态污染 | 使用 try-finally 确保 base.evaluate() 只被调用一次 |
| 异常被吞没 | 测试失败时日志丢失,无法定位原因 | 在 catch 块中重新抛出异常,或使用 Throwables.propagateIfPossible() |
| 执行顺序颠倒 | 资源初始化和清理错乱 | 检查 @Rule 字段声明顺序,外层规则应在字段列表中靠前 |
3.9 性能优化建议
- 轻量化装饰器: 避免在
apply()方法中执行重量级 I/O 操作,因为apply()本身在测试准备阶段执行,不影响evaluate()时间。 - 避免深链: 装饰器层次过多(超过 10 层)会增加调用栈深度,影响 JIT 优化。将多个简单装饰器合并为一个复合装饰器可以降低层次。
- 缓存 Description 对象: 如果装饰器需要频繁读取
Description元数据,可以在apply()阶段预先解析并缓存,减少evaluate()阶段的反射调用。