第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() 前后植入日志记录、资源初始化/清理、超时控制、重试逻辑等行为,而无需修改测试方法本身的代码。
TestRule 装饰器链执行流程 @Test 测试方法 TestRule 装饰器链(按 @Rule 声明顺序执行) Rule 1: apply() 日志记录 / 资源初始化 Rule 2: apply() 超时控制 / 异常处理 Rule 3: apply() 结果验证 / 清理释放 Statement.evaluate() 调用顺序:测试方法 → Rule1.apply → Rule2.apply → Rule3.apply → evaluate() → 逆向返回

3.2 实现流程

编写自定义 TestRule 只需四个步骤,每一步都有清晰的关注点:

  1. 创建类实现 TestRule 接口 — 通常命名为 XxxRule 以保持命名规范。
  2. 重写 apply(Statement base, Description description) 方法 — base 为原始 Statement,description 携带测试方法元信息(如方法名、注解)。
  3. 包装 Statement — 返回匿名内部类或 Lambda 表达式的 Statement,在 evaluate() 中定义增强逻辑。
  4. 在测试类中使用 @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() 前后置增强的编写模式。这是后续编写更复杂夹具(如重试、超时、数据库事务回滚)的基础骨架。