第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);
}

三个关键角色的职责如下:

角色类型职责说明
baseStatementJUnit 创建的原始执行单元,封装了待执行的测试方法
descriptionDescription测试方法的元数据容器,提供方法名、类名、注解等信息
返回值Statement经过包装后的新执行单元,在 evaluate() 中控制前后逻辑

Statement 是一个抽象类,仅包含一个抽象方法 evaluate()。自定义 TestRule 的本质就是创建一个新的 Statement,在其 evaluate() 中包裹 base.evaluate(),并在调用前后插入夹具逻辑。

核心要点: TestRule 的 apply 方法不会立即执行任何测试逻辑,它只是返回一个包装后的 Statement。真正的执行发生在 JUnit 调用该 Statement 的 evaluate() 方法时。这种延迟执行的设计使得 Rule 可以嵌套组合,形成多层装饰链。
TestRule 执行流程 @Test 方法 JUnit 创建 Statement TestRule.apply() 前置逻辑(Before) base.evaluate() 测试 后置逻辑(After) 核心机制 通过 apply() 包装 Statement,在 evaluate() 前后插入夹具逻辑

2.2 实现步骤

按照以下 7 个步骤创建你的第一个自定义 TestRule:

  1. 创建实现类 — 新建一个类,声明实现 TestRule 接口。
  2. 重写 apply 方法 — 签名必须为 Statement apply(Statement base, Description description)。
  3. 创建匿名 Statement — 在 apply 方法体内返回一个新的 Statement 匿名子类。
  4. 添加前置逻辑 — 在匿名类的 evaluate() 方法中,在调用 base.evaluate() 之前编写准备代码。
  5. 调用 base.evaluate() — 执行原始测试方法,这是整个流程的必经环节。
  6. 添加后置逻辑 — 将后置代码放在 finally 块中,确保无论测试是否抛出异常都会执行。
  7. 返回包装后的 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 注解时需遵守以下规则:

配置项要求说明
字段修饰符publicJUnit 通过反射直接访问 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 的调试可以从以下三个角度切入:

  1. 控制台日志 — 在 apply 和 evaluate 方法中插入 System.out 或 System.err 输出,确认方法被调用的时机和参数内容。
  2. IDE 断点调试 — 在 apply() 方法、evaluate() 方法的第一行、base.evaluate() 调用处分别设置断点,观察调用栈和变量状态。
  3. 异常堆栈分析 — 在 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 字段不是 publicJUnit 抛出 IllegalAccessException将 @Rule 字段声明为 public

优化建议:

  • 每个 Rule 只关注一个横切关注点(计时、日志、事务、重试),保持 单一职责。
  • 如果多个 Rule 需要共享数据,通过 Description 的 getAnnotations() 获取测试方法上的自定义注解来传递配置。
  • 避免在 Rule 中持有测试方法的状态变量,防止并发执行时的线程安全问题。
  • 优先使用 JUnit 内置 Rule(TemporaryFolder、ExpectedException 等),只有在内置 Rule 不满足需求时才自定义。
性能陷阱: 不要在 evaluate() 的前后逻辑中执行数据库连接、文件读写等重量级操作,除非 Rule 的设计目的就是管理这些资源。否则会显著拖慢测试套件的执行速度。