第3章:编写第一个自定义TestRule
3.1 核心原理
TestRule 是 JUnit 4.7 引入的测试规则接口,它替代了旧版 MethodRule,提供更灵活、可组合的测试生命周期干预能力。理解它的运行原理是编写自定义夹具的前提。
| 概念 | 说明 |
|---|---|
| TestRule 接口 | 函数式接口,仅包含一个方法 apply(Statement base, Description description) |
| Statement | 可执行语句封装,其 evaluate() 方法内部包含待执行的测试逻辑 |
| apply 方法 | 接收原始 Statement,返回包装后的新 Statement,可在 evaluate() 前后插入自定义行为 |
| @Rule 注解 | 标记在 TestRule 字段上,JUnit 运行器自动识别并应用该规则 |
当 JUnit 执行测试时,运行器扫描测试类中所有标注 @Rule 的字段,依次调用每个规则对象的 apply 方法,将前一个规则输出的 Statement 传入下一个规则,形成 装饰器链。最终链末端的 evaluate() 被调用时,实际执行所有嵌套的增强逻辑。
关键理解: 每个 TestRule 本质上是围绕测试方法的一个装饰器。你可以在
evaluate() 前后植入日志记录、资源初始化/清理、超时控制、重试逻辑等行为,而无需修改测试方法本身的代码。
3.2 实现流程
编写自定义 TestRule 只需四个步骤,每一步都有清晰的关注点:
- 创建类实现
TestRule接口 — 通常命名为XxxRule以保持命名规范。 - 重写
apply(Statement base, Description description)方法 —base为原始 Statement,description携带测试方法元信息(如方法名、注解)。 - 包装 Statement — 返回匿名内部类或 Lambda 表达式的 Statement,在
evaluate()中定义增强逻辑。 - 在测试类中使用
@Rule注解 — 将自定义 Rule 实例声明为public字段并标注@Rule。
命名惯例: 自定义 Rule 类名后缀统一为
Rule,如 LogRule、RetryRule、EnvRule。字段名通常使用 xxxRule 驼峰格式。
3.3 代码实现
下面是一个完整的自定义 TestRule 实现,它在每条测试执行前后打印时间戳,并记录方法名称:
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 自定义 TestRule:在测试执行前后打印时间戳和方法名
*/
public class TimerRule implements TestRule {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
@Override
public Statement apply(Statement base, Description description) {
// 返回包装后的 Statement
return new Statement() {
@Override
public void evaluate() throws Throwable {
// === 前置增强 ===
String methodName = description.getMethodName();
String className = description.getClassName();
System.out.println("[TimerRule] 开始执行: " + className + "#" + methodName
+ " @ " + LocalDateTime.now().format(FORMATTER));
long startNanos = System.nanoTime();
try {
// 执行原始测试逻辑
base.evaluate();
} finally {
// === 后置增强(确保无论成功或异常都执行)===
long elapsedNanos = System.nanoTime() - startNanos;
double elapsedMs = elapsedNanos / 1_000_000.0;
System.out.println("[TimerRule] 结束执行: " + className + "#" + methodName
+ " @ " + LocalDateTime.now().format(FORMATTER)
+ " | 耗时: " + String.format("%.2f", elapsedMs) + " ms");
}
}
};
}
}
3.4 在测试类中使用 @Rule
将自定义 Rule 集成到测试类的两种方式:
| 方式 | 代码示例 | 适用场景 |
|---|---|---|
| 字段注入 | @Rule public TimerRule timerRule = new TimerRule(); | 最常用,每个测试方法都应用该规则 |
| 规则链组合 | @Rule public RuleChain chain = RuleChain.outerRule(a).around(b); | 需要严格控制多个规则的执行顺序 |
以下是一个完整的测试类示例:
import org.junit.Rule;
import org.junit.Test;
public class UserServiceTest {
@Rule
public TimerRule timerRule = new TimerRule();
@Test
public void shouldCreateUserSuccessfully() {
// 模拟测试逻辑
System.out.println("执行 shouldCreateUserSuccessfully ...");
// 业务断言
assert 2 + 2 == 4 : "基础数学验证";
}
@Test
public void shouldThrowExceptionWhenEmailInvalid() {
System.out.println("执行 shouldThrowExceptionWhenEmailInvalid ...");
// 模拟异常场景
try {
throw new IllegalArgumentException("无效的邮箱格式");
} catch (IllegalArgumentException e) {
// 预期的异常
}
}
}
3.5 调试与验证
运行 UserServiceTest 后,控制台输出示例:
[TimerRule] 开始执行: UserServiceTest#shouldCreateUserSuccessfully @ 2025-03-28 14:32:15.789
执行 shouldCreateUserSuccessfully ...
[TimerRule] 结束执行: UserServiceTest#shouldCreateUserSuccessfully @ 2025-03-28 14:32:15.812 | 耗时: 23.45 ms
[TimerRule] 开始执行: UserServiceTest#shouldThrowExceptionWhenEmailInvalid @ 2025-03-28 14:32:15.823
执行 shouldThrowExceptionWhenEmailInvalid ...
[TimerRule] 结束执行: UserServiceTest#shouldThrowExceptionWhenEmailInvalid @ 2025-03-28 14:32:15.831 | 耗时: 8.12 ms
验证要点: 确认每个测试方法前后都输出了时间戳;异常测试方法也能正确捕获并执行后置逻辑;耗时计算为毫秒级且数值合理。
3.6 常见错误与解决
| 错误场景 | 现象 | 根因与解决方案 |
|---|---|---|
Rule 字段为 private |
JUnit 运行器忽略该 Rule | @Rule 字段必须声明为 public,否则运行器无法访问。 |
evaluate() 中未调用 base.evaluate() |
测试方法体完全不被执行,测试通过但无实际覆盖 | 必须在 evaluate() 内显式调用 base.evaluate(),否则原始测试逻辑被吞没。 |
| 异常未正常传递 | 测试本应失败却通过,或异常信息丢失 | 使用 try-finally 而非 try-catch 包裹 base.evaluate(),避免吞异常。 |
| 多个 Rule 顺序混乱 | 预期前置/后置执行顺序不符合设计 | 使用 RuleChain 明确指定包裹顺序,或重构为单一 Rule 内部分层。 |
重要提醒: 不要在
evaluate() 的 finally 块中返回自定义结果或修改异常对象。后置逻辑应专注于清理和记录,不应改变测试结果的传播链。
3.7 性能优化建议
自定义 TestRule 虽然灵活,但滥用会影响测试套件整体性能。以下为关键优化策略:
- 避免繁重 I/O — 不要在
evaluate()内执行数据库连接、文件写入或网络请求;如需资源初始化,使用@BeforeClass一次完成。 - 细粒度预热 — 对于高精度计时 Rule,在
@BeforeClass中触发一次空跑以消除 JVM 预热偏差。 - 条件跳过 — 利用
description.getAnnotation()检测自定义注解,仅在特定注解标记的方法上执行增强逻辑。 - 复用 Statement 实例 — 如果多个测试方法使用相同的增强逻辑,考虑将包装逻辑提取为静态方法,减少匿名类创建开销。
经验值: 一个轻量级的日志/计时 Rule 对单条测试的耗时增加应控制在 0.5 ms 以内。若超过 1 ms,需检查是否有反射调用、字符串拼接过多或意外 I/O。
3.8 小结
本章通过实现 TimerRule 完整走通了自定义 TestRule 的四个步骤:实现接口、重写 apply、包装 Statement、使用 @Rule 注解。掌握了装饰器链的执行顺序以及 evaluate() 前后置增强的编写模式。这是后续编写更复杂夹具(如重试、超时、数据库事务回滚)的基础骨架。