第2章 创建第一个自定义 TestRule
2.1 原理说明
TestRule 是 JUnit 4.7 引入的接口,位于 org.junit.rules 包中。它替代了早期版本的 MethodRule,提供了更稳定、更灵活的测试夹具控制机制。TestRule 的核心是 装饰器模式——通过包装原始的 Statement 对象,在 evaluate() 方法的前后插入自定义逻辑,从而实现对测试方法的 AOP 风格控制。
接口定义非常简单,只包含一个方法:
public interface TestRule {
Statement apply(Statement base, Description description);
}
三个关键角色的职责如下:
| 角色 | 类型 | 职责说明 |
|---|---|---|
| base | Statement | JUnit 创建的原始执行单元,封装了待执行的测试方法 |
| description | Description | 测试方法的元数据容器,提供方法名、类名、注解等信息 |
| 返回值 | Statement | 经过包装后的新执行单元,在 evaluate() 中控制前后逻辑 |
Statement 是一个抽象类,仅包含一个抽象方法 evaluate()。自定义 TestRule 的本质就是创建一个新的 Statement,在其 evaluate() 中包裹 base.evaluate(),并在调用前后插入夹具逻辑。
核心要点: TestRule 的 apply 方法不会立即执行任何测试逻辑,它只是返回一个包装后的 Statement。真正的执行发生在 JUnit 调用该 Statement 的 evaluate() 方法时。这种延迟执行的设计使得 Rule 可以嵌套组合,形成多层装饰链。
2.2 实现步骤
按照以下 7 个步骤创建你的第一个自定义 TestRule:
- 创建实现类 — 新建一个类,声明实现
TestRule接口。 - 重写 apply 方法 — 签名必须为
Statement apply(Statement base, Description description)。 - 创建匿名 Statement — 在 apply 方法体内返回一个新的
Statement匿名子类。 - 添加前置逻辑 — 在匿名类的
evaluate()方法中,在调用base.evaluate()之前编写准备代码。 - 调用 base.evaluate() — 执行原始测试方法,这是整个流程的必经环节。
- 添加后置逻辑 — 将后置代码放在
finally块中,确保无论测试是否抛出异常都会执行。 - 返回包装后的 Statement — apply 方法返回新建的 Statement 实例,JUnit 会在适当时候调用它的
evaluate()。
关键约束: 第 6 步的 finally 块不可省略。如果后置逻辑直接写在 try 块末尾,测试异常会导致后置逻辑被跳过,造成资源泄漏或状态污染。
2.3 代码示例
下面实现一个 MethodTimerRule,自动记录每个测试方法的执行耗时:
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
public class MethodTimerRule implements TestRule {
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
// ---- 前置逻辑 ----
long start = System.nanoTime();
String methodName = description.getMethodName();
System.out.println("[Timer] 开始执行: " + methodName);
try {
// 执行原始测试方法
base.evaluate();
} finally {
// ---- 后置逻辑(确保执行) ----
long elapsed = System.nanoTime() - start;
double millis = elapsed / 1_000_000.0;
System.out.println("[Timer] 方法 " + methodName
+ " 耗时: " + String.format("%.3f", millis) + " ms");
}
}
};
}
}
在测试类中使用 @Rule 注解引入该 Rule:
import org.junit.Rule;
import org.junit.Test;
public class MethodTimerRuleTest {
@Rule
public MethodTimerRule timerRule = new MethodTimerRule();
@Test
public void testQuickMethod() {
System.out.println("执行快速测试...");
}
@Test
public void testSlowMethod() throws InterruptedException {
System.out.println("执行慢速测试...");
Thread.sleep(200);
}
}
执行输出示例:
[Timer] 开始执行: testQuickMethod
执行快速测试...
[Timer] 方法 testQuickMethod 耗时: 1.234 ms
[Timer] 开始执行: testSlowMethod
执行慢速测试...
[Timer] 方法 testSlowMethod 耗时: 201.567 ms
设计要点: 上述示例中,
description.getMethodName() 可以精确获取当前执行的方法名,无需硬编码。这种元数据访问能力是 TestRule 相比直接 AOP 方案的重要优势。
2.4 配置细节
使用 @Rule 注解时需遵守以下规则:
| 配置项 | 要求 | 说明 |
|---|---|---|
| 字段修饰符 | public | JUnit 通过反射直接访问 Rule 字段,必须为 public |
| 字段类型 | 实现 TestRule 的类 | 可以是自定义类或 JUnit 内置 Rule(如 ExpectedException) |
| 字段是否为 static | 通常为非 static | 每个测试实例拥有独立的 Rule 实例;若为 static 则跨实例共享 |
| 多个 Rule 顺序 | 字段声明顺序 | JUnit 4.7+ 按照字段在类中声明的先后顺序应用 Rule |
| 方法形式 | @Rule 可修饰方法 | 方法必须返回 TestRule 类型,且签名符合规范 |
多个 Rule 的声明示例:
public class MultiRuleTest {
@Rule
public MethodTimerRule timerRule = new MethodTimerRule();
@Rule
public RetryRule retryRule = new RetryRule(3); // 自定义重试 Rule
@Test
public void testWithMultipleRules() {
// timerRule 先包装,retryRule 再包装外层
}
}
外层 Rule 包装内层 Rule,最终形成装饰器链。执行顺序为:retryRule → timerRule → @Test。
2.5 调试方法
自定义 TestRule 的调试可以从以下三个角度切入:
- 控制台日志 — 在 apply 和 evaluate 方法中插入 System.out 或 System.err 输出,确认方法被调用的时机和参数内容。
- IDE 断点调试 — 在
apply()方法、evaluate()方法的第一行、base.evaluate()调用处分别设置断点,观察调用栈和变量状态。 - 异常堆栈分析 — 在 catch 块中打印完整堆栈,追踪异常传播路径:
try {
base.evaluate();
} catch (Throwable t) {
System.err.println("[Rule] 测试异常: " + t.getMessage());
t.printStackTrace(); // 打印完整堆栈
throw t; // 必须重新抛出,否则 JUnit 无法感知失败
} finally {
// 后置逻辑
}
调试技巧: 在
apply() 方法中打印 description.getClassName() + "#" + description.getMethodName(),可以快速确认 Rule 被应用到了哪些测试方法上,避免 Rule 遗漏或重复应用。
2.6 常见错误及优化
| 错误类型 | 现象 | 解决方案 |
|---|---|---|
忘记调用 base.evaluate() | 测试方法始终不执行,总是通过 | 在 evaluate() 中必须有一处调用 base.evaluate() |
| 后置逻辑未放在 finally 中 | 测试异常时资源未释放 | 始终使用 try-finally 包裹后置代码 |
| 在 apply 中执行耗时操作 | 所有测试方法都变慢 | apply 只做 Statement 构建,不要做 IO 或重计算 |
| 多个 Rule 相互依赖 | 执行顺序导致数据错乱 | 使用 RuleChain 显式指定顺序 |
| Rule 字段不是 public | JUnit 抛出 IllegalAccessException | 将 @Rule 字段声明为 public |
优化建议:
- 每个 Rule 只关注一个横切关注点(计时、日志、事务、重试),保持 单一职责。
- 如果多个 Rule 需要共享数据,通过
Description的getAnnotations()获取测试方法上的自定义注解来传递配置。 - 避免在 Rule 中持有测试方法的状态变量,防止并发执行时的线程安全问题。
- 优先使用 JUnit 内置 Rule(
TemporaryFolder、ExpectedException等),只有在内置 Rule 不满足需求时才自定义。
性能陷阱: 不要在
evaluate() 的前后逻辑中执行数据库连接、文件读写等重量级操作,除非 Rule 的设计目的就是管理这些资源。否则会显著拖慢测试套件的执行速度。