- 测试
- 开发工具
【免费下载链接】junit4
A programmer-oriented testing framework for Java — :warning: maintenance mode
导读
本文基于 JUnit 4 官方发布说明 doc/ReleaseNotes4.13.2.md 展开,深入剖析该版本在三方面的核心变更:FailOnTimeout(含Timeout规则)线程组行为的回归修复、AssumptionViolatedException携带 Hamcrest Matcher 时的序列化修复,并对照 4.13.1 的安全与 API 变更背景。读完本文,你将理解 JUnit 4 超时机制中"卡死线程检测"(lookForStuckThread)的底层线程模型、ThreadGroup泄漏问题的来龙去脉,掌握如何用Timeout.Builder精确控制超时行为,以及assumeThat()抛出的异常为何在分布式测试/序列化场景下曾引发NotSerializableException及其修复原理。
版本背景:JUnit 4 维护模式的最后一次系列修复
JUnit 4 系列已进入维护模式(项目描述明确标注 "maintenance mode")。4.13.2 是继 4.13、4.13.1 之后的又一个补丁版本,其发布说明篇幅虽短,却聚焦于两个影响面较大的历史问题:线程组泄漏回归与异常序列化缺陷。它们都属于"修复本身引入新问题"(regression)的典型案例,值得开发者深究其来龙去脉。
Rules 之变:FailOnTimeout 的 ThreadGroup 处理方式
问题起点:4.13 中显式销毁 ThreadGroup 的尝试与反噬
4.13.2 发布说明首先回顾了 JUnit 4.13 中的一次尝试:为了修复超时测试运行时创建的ThreadGroup实例泄漏问题(对应 pull request #1517),当时的方案是在测试执行完毕后**显式销毁(destroy)**为限时测试创建的ThreadGroup。然而大量用户反馈:显式销毁ThreadGroup本身会带来新的问题(例如破坏ThreadGroupContext等关联状态、在特定 JVM 版本上抛出不期望的异常)。
新方案:setDaemon(true) 替代 destroy()
4.13.2 将实现改为调用ThreadGroup.setDaemon(true)来替代显式销毁。在 src/main/java/org/junit/internal/runners/statements/FailOnTimeout.java 的threadGroupForNewThread()方法中可以看到完整实现:
private ThreadGroup threadGroupForNewThread() { if (!lookForStuckThread) { // Use the default ThreadGroup (usually the one from the current // thread). return null; } // Create the thread in a new ThreadGroup, so if the time-limited thread // becomes stuck, getStuckThread() can find the thread likely to be the // culprit. ThreadGroup threadGroup = new ThreadGroup("FailOnTimeoutGroup"); if (!threadGroup.isDaemon()) { // Mark the new ThreadGroup as a daemon thread group, so it will be // destroyed after the time-limited thread completes. By ensuring the // ThreadGroup is destroyed, any data associated with the ThreadGroup // (ex: via java.beans.ThreadGroupContext) is destroyed. try { threadGroup.setDaemon(true); } catch (SecurityException e) { // Swallow the exception to keep the same behavior as in JUnit 4.12. } } return threadGroup; }关键点解读:
- 当
lookForStuckThread为true时,仍会创建名为"FailOnTimeoutGroup"的新ThreadGroup(这是定位卡死线程所必需的,见下文"卡死线程检测原理"); - 创建后立即调用
setDaemon(true)将其标记为守护线程组。JVM 规范保证:守护线程组在其最后一个线程终止后会被自动销毁,因此无需手动 destroy,既解决泄漏,又避免显式销毁带来的副作用; - 代码对
SecurityException做了吞掉处理(注释明确说明这是为了保持与 4.12 一致的行为)。
这一行为在测试 src/test/java/org/junit/internal/runners/statements/FailOnTimeoutTest.java 中得到了双重验证:
lookingForStuckThread_threadGroupNotLeaked:断言内部线程使用与外部不同的线程组、该线程组是 daemon 线程组,并在测试结束后被自动销毁(isDestroyed()为 true);notLookingForStuckThread_usesSameThreadGroup:断言未开启卡死线程检测时,内部线程复用外部线程组。
行为回归:仅当 lookForStuckThread=true 时才创建新 ThreadGroup
第二个变更(对应 pull request #1691)进一步收窄了创建ThreadGroup的触发条件。历史背景:
- JUnit 4.12 引入
Timeout规则的"卡死线程栈回溯"能力(对应 pull request #742),该能力默认关闭,需通过Timeout.Builder.lookForStuckThread(boolean)传入true来按需开启; - 但实现上,从 4.12 起所有限时测试都被放到了一个新的
ThreadGroup中运行,即使没有调用lookForStuckThread()也是如此。这一"隐蔽的行为变化"导致部分测试出现可见差异——发布说明特别点名了依赖java.beans.ThreadGroupContext的代码。
4.13.2 修正为:只有调用者显式传入Timeout.Builder.lookForStuckThread(true)时才创建新ThreadGroup。未开启该特性的超时测试,其线程行为与 JUnit 4.11 一致(也更接近无超时测试)。源码中threadGroupForNewThread()的第一段分支正是这一逻辑的直接体现:
if (!lookForStuckThread) { // Use the default ThreadGroup (usually the one from the current thread). return null; }需要特别提醒的是发布说明中的兼容性警告:
不幸的是,这可能导致自 4.12 发布以来编写或更新的测试出现可见行为变化。如果此变更对你的测试产生不利影响,可以通过 builder 创建
Timeout规则并调用Timeout.Builder.lookForStuckThread(true)。
卡死线程检测原理:从源码看 FailOnTimeout 的线程模型
为深入理解上述两个修复,有必要厘清FailOnTimeout的完整执行流程(源码位于 src/main/java/org/junit/internal/runners/statements/FailOnTimeout.java):
evaluate()将测试语句包装为CallableStatement,放入FutureTask;- 在新建的线程(线程名为
"Time-limited test")中执行测试,线程本身设为 daemon:Thread thread = new Thread(threadGroup, task, "Time-limited test"); thread.setDaemon(true); thread.start(); - 通过
FutureTask.get(timeout, timeUnit)等待结果,超时则抛出TimeoutException; createTimeoutException()在超时时取出测试线程的栈回溯设置到TestTimedOutException,并调用thread.interrupt()尝试中断;- 若开启
lookForStuckThread,还会调用getStuckThread():枚举"FailOnTimeoutGroup"线程组内的所有线程,在 RUNNABLE 状态的线程中挑出 CPU 时间最长者(通过 src/main/java/org/junit/internal/management/ThreadMXBean.java 提供的getThreadCpuTime),将其栈回溯以MultipleFailureException形式一并抛出,帮助定位"看起来卡住"的线程。
因此,ThreadGroup在这里承担着"隔离与搜索范围"的双重角色:只有把测试线程放进独立线程组,getStuckThread()才能精准枚举"本次测试派生出的线程"而不会误伤其他并发测试的线程。这也是为什么lookForStuckThread开启与否直接决定了是否创建新线程组。
实战用法:通过 Timeout.Builder 精确控制
org.junit.rules.Timeout(源码见 src/main/java/org/junit/rules/Timeout.java)自 4.12 起提供 Builder 模式。常用的两种写法:
// 方式一:传统写法,不开启卡死线程检测 @Rule public Timeout globalTimeout = Timeout.millis(20); // 方式二:Builder 写法,开启卡死线程检测(这也是 4.13.2 唯一会创建独立 ThreadGroup 的方式) @Rule public Timeout globalTimeout = Timeout.builder() .withTimeout(20, TimeUnit.MILLISECONDS) .withLookingForStuckThread(true) .build();Builder 的关键方法与默认值(见 src/main/java/org/junit/rules/Timeout.java):
| 方法 | 作用 | 默认值 |
|---|---|---|
withTimeout(long, TimeUnit) | 指定超时时长与时间单位 | 0秒(0 表示不超时,但测试仍在线程中运行,可用于按环境动态关闭超时) |
withLookingForStuckThread(boolean) | 是否在超时时查找卡死线程并 dump 其栈回溯(该特性标记为 experimental) | false |
build() | 生成Timeout实例 | — |
底层FailOnTimeout.Builder(src/main/java/org/junit/internal/runners/statements/FailOnTimeout.java)还有两个值得注意的校验:withTimeout传入负数会抛IllegalArgumentException("timeout must be non-negative"),传入null的TimeUnit会抛NullPointerException。
Timeout.apply()将规则包裹到语句上时若参数非法,会生成一个在evaluate()时抛出RuntimeException("Invalid parameters for Timeout", e)的兜底语句,保证规则失败不会静默吞掉配置错误。
Exceptions 之变:AssumptionViolatedException 的序列化修复
问题现象:NotSerializableException(issue #1192)
第二个修复对应 pull request #1654 的实现:
public static <T> void assumeThat(T actual, Matcher<T> matcher) { if (!matcher.matches(actual)) { throw new AssumptionViolatedException(actual, matcher); } } public static <T> void assumeThat(String message, T actual, Matcher<T> matcher) { if (!matcher.matches(actual)) { throw new AssumptionViolatedException(message, actual, matcher); } }根因:AssumptionViolatedException作为Throwable必须是Serializable的,但其内部字段保存了Matcher和实际值Object——绝大多数 Hamcrest Matcher 实现(以及任意用户对象值)并不实现Serializable。一旦该异常被序列化(例如在分布式测试环境中跨进程传递、或通过 RMI/消息队列上报测试结果),就会抛出NotSerializableException。
修复方案:自定义 writeObject + 可序列化包装描述
核心修复位于 src/main/java/org/junit/internal/AssumptionViolatedException.java 的writeObject方法:
private void writeObject(ObjectOutputStream objectOutputStream) throws IOException { ObjectOutputStream.PutField putField = objectOutputStream.putFields(); putField.put("fAssumption", fAssumption); putField.put("fValueMatcher", fValueMatcher); // We have to wrap the matcher into a serializable form. putField.put("fMatcher", SerializableMatcherDescription.asSerializableMatcher(fMatcher)); // We have to wrap the value inside a non-String class ... Wrapping it makes sure // that the description of a serialized and non-serialized instance produce // the exact same description putField.put("fValue", SerializableValueDescription.asSerializableValue(fValue)); objectOutputStream.writeFields(); }该实现刻意不实现readObject(注释说明这是为了保证向后兼容和遵循 Java 序列化的标准方式),只覆盖writeObject,通过PutField将两个原本可能不可序列化的字段替换为可序列化的描述包装:
- Matcher 的包装:src/main/java/org/junit/internal/SerializableMatcherDescription.java ——
asSerializableMatcher()检查 matcher 是否为空或已实现Serializable,是则原样返回,否则用StringDescription.asString(matcher)预先把其文本描述捕获到一个BaseMatcher<String>子类中。由于异常在运行时只消费 matcher 的描述(见describeTo),序列化后描述不会丢失; - 值的包装:src/main/java/org/junit/internal/SerializableValueDescription.java ——
asSerializableValue()同样检查值是否可序列化,否则用String.valueOf(value)捕获其字符串形式。包装为非 String 类而非直接写 String,是为了保证序列化前后describeTo产生的描述完全一致(JUnit 的Description对 String 值加引号"..."、对其他对象加尖括号<...>)。
此外还保留了fValueMatcher(表示构造时是否传入 value+matcher)与fAssumption(提示消息)字段,配合serialVersionUID = 2L维持与历史序列化数据的兼容(字段保留f前缀正是为了序列化兼容,参见 src/main/java/org/junit/internal/AssumptionViolatedException.java 的注释)。
测试验证:序列化往返与反向兼容
src/test/java/org/junit/AssumptionViolatedExceptionTest.java 提供了充分的验证用例:
unserializableValueAndMatcherCanBeSerialized:用不可序列化的值 + 不可序列化的 matcher 构造异常,经ObjectOutputStream/ObjectInputStream往返后,反序列化实例的消息与描述(StringDescription.asString)与原实例完全一致;serializableValueAndMatcherCanBeSerialized、nullValueAndMatcherCanBeSerialized:覆盖可序列化值与空值场景;assumptionViolatedExceptionWithoutValueAndMatcherCanBeReserialized_v4_13/...WithValueAndMatcherCanBeReserialized_v4_13:从测试资源中读取4.13 时代序列化的二进制文件反序列化,验证旧数据在新版本仍可读,即向后兼容性(backwards compatibility)得到保证。
对比断言可知,校验点正是"message 一致 + description 一致",并刻意跳过 stackTrace(受启动方式影响)与 cause(由父类负责)。
关联背景:4.13.1 的两项先行变更
4.13.2 的修复建立在此前 4.13.1 的基础上(见 doc/ReleaseNotes4.13.1.md),理解它们有助于把握版本演进脉络:
- 安全修复:
TemporaryFolder在 Java 7+ 上限制临时目录访问。这是一项本地信息泄露(local information disclosure)漏洞修复,对应安全公告 GHSA-269g-pwp5-87pp。TemporaryFolder的实现位于 src/main/java/org/junit/rules/TemporaryFolder.java,创建临时目录时优先使用 NIO 的Files.createTempDirectory(Java 7+ 路径,见createTemporaryFolderWithNioApi),Java 5/6 则回退到File.createTempFile加mkdir()的旧方案(createTemporaryFolderWithFileApi,最多重试 10000 次)。该规则支持TemporaryFolder.builder().assureDeletion().build()的保证删除模式——删除失败会以AssertionError使测试失败; - API 变更:
FrameworkField构造函数从包私有提升为 public。此前自定义 Runner 只能构造FrameworkMethod实例而不能构造FrameworkField实例,此变更后两者皆可(见 src/main/java/org/junit/runners/model/FrameworkField.java 中"Access relaxed to public since version 4.13.1"的注释)。FrameworkField目前主要用于BlockJUnit4ClassRunner处理@Rule字段,但自定义 Runner 可以做其他用途。
升级与兼容性建议
综合 4.13.2 发布说明与源码事实,给 JUnit 4 使用者如下建议:
- 超时测试依赖线程组隔离的:如果你的被测代码依赖测试线程运行在特定线程组、或依赖
java.beans.ThreadGroupContext之类的状态,请显式通过Timeout.builder().withLookingForStuckThread(true).build()创建规则,以复现 4.12/4.13 的行为; - 在分布式/序列化环境下使用
assumeThat的:升级到 4.13.2 后,AssumptionViolatedException可携带任意(不可序列化的)matcher 与值进行序列化传输,无需再自行兜底转换; - 从 4.11 及更早版本升级的:超时测试的默认线程行为将更接近 4.11——即不创建独立
ThreadGroup;若你的测试在 4.12 之后出现过与线程组相关的诡异行为,4.13.2 很可能已经将其消除。
以上分析全部基于当前仓库实际代码与发布说明,涉及的验证路径包括 FailOnTimeout.java、Timeout.java、AssumptionViolatedException.java 及对应的测试文件,读者可自行进入仓库对应路径核对细节。
- 测试
- 开发工具
【免费下载链接】junit4
A programmer-oriented testing framework for Java — :warning: maintenance mode
相关推荐
JUnit 4.8 深度解析:Categories 测试分类机制与 Result 线程安全修复
JUnit 4.8 深度解析:Categories 测试分类机制与 Result 线程安全修复 本文以 JUnit 4.8 官方发布说明( doc/Releas
测试开发工具RuboCop v0.74.0 发布要点深度解析:ERB 新参数自动修正与 8 项核心缺陷修复
RuboCop v0.74.0 发布要点深度解析:ERB 新参数自动修正与 8 项核心缺陷修复 RuboCop v0.74.0 是该项目在迈向 1.0 稳定版之
代码质量Lint格式化静态分析开发工具Eclipse Mosquitto 1.0.2 版本发布深度解读:持久化重启修复、线程修复与五处变更全解析
Eclipse Mosquitto 1.0.2 版本发布深度解读:持久化重启修复、线程修复与五处变更全解析 本文为 Mosquitto 1.0.2 版本发布公告
物联网消息队列后端网络/通信
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考