本文是对 How Uber Executed A JUnit Migration at Massive Scale 的整理与翻译。内容结构概览本文会围绕 Uber 如何在 Java monorepo 中完成 JUnit 4 到 JUnit 5 的大规模迁移展开主要包括为什么 Uber 必须从 JUnit 4 迁移到 JUnit 5这次迁移的规模60 多万个 JUnit 4 测试、1500 万行代码为什么这不是简单依赖升级而是框架迁移迁移结果4 个月、75000 多个测试类、125 万行代码、5000 多个 diff迁移前提先让 Bazel 支持 JUnit PlatformJUnit Platform、Vintage Engine、Jupiter Engine 分别承担什么角色Uber 为什么需要自定义 test runner为什么选择 OpenRewrite 作为自动代码改造工具OpenRewrite 的 LST、Recipe、Visitor 是怎么工作的Bazel 原生不支持 OpenRewriteUber 如何通过 Bazel Aspect 集成自动生成 patch 和 datatables 的迁移基础设施如何最大化迁移覆盖率同时避免错误迁移precondition 机制为什么很关键如何组合 OpenRewrite 官方 recipe 和 Uber 自定义 recipe如何处理 JUnit 4 runner、rule、base test class 等复杂模式如何用搜索 recipe 统计最常见测试工具用法决定优先支持哪些模式完整迁移 recipe 的执行阶段单个 Bazel test target 的端到端迁移流程如何自动更新源码、BUILD 配置和测试依赖为什么迁移必须以测试通过为准而不是只看代码能否改写如何用 Shepherd 在数千个 target 上批量生成 diff为什么要采用增量 rollout而不是一次性迁移所有代码失败分析、补充 recipe、重新 rollout 的反馈循环为什么生成式 AI 没有成为主迁移方案这次迁移给大型代码库现代化带来的经验未来可复用方向Spring Boot 3、Guava 到标准 Java API、Joda-Time 到 java.time一、为什么要从 JUnit 4 迁移到 JUnit 5Uber 的 Java monorepo 曾长期以 JUnit 4 作为主要测试框架。JUnit 4 当然还能工作很多 Java 项目也仍然有大量 JUnit 4 测试。但问题在于JUnit 4 已经不再是现代 Java 测试生态的中心。JUnit 5 已经发布多年带来了新的测试能力、更灵活的扩展机制、更现代的 API 和更好的测试平台抽象。更关键的是JUnit 4 的活跃开发已经停止。这意味着未来 JUnit 4 不再获得新的功能、bug 修复和安全修复。继续长期依赖 JUnit 4会让代码库逐渐落后于 Java 测试生态也会阻碍团队采用 JUnit 5 的新能力。对 Uber 来说这不是“想不想升级”的问题而是迟早必须做的技术现代化。但真正困难的是规模。Uber 的 Java monorepo 中有超过 600,000 个 JUnit 4 测试 约 15,000,000 行代码这意味着迁移不是几个工程师改几天代码就能完成。如果让每个业务团队自己手动迁移成本会非常高而且迁移质量不一致。开发者需要学习 Jupiter API需要理解 JUnit 5 extension 机制需要替换 custom runner、rule、base class还要处理构建配置和依赖变化。对业务团队来说这会占用大量工程时间。所以 Uber 面临的不是单纯技术问题而是技术和组织问题叠加如何把 JUnit 4 测试迁移到 JUnit 5 如何尽量自动化 如何避免打断开发者日常工作 如何保证迁移后测试行为不变 如何在 Bazel monorepo 中批量执行 如何让迁移过程可重复、可验证、可回滚最终Uber 选择了一条集中式、自动化、可增量推进的迁移路线。二、这次迁移取得了什么结果原文给出的迁移结果非常明确。Uber 通过自动化迁移完成了这些工作让 Bazel 代码库支持 JUnit 5 所有测试模块统一接入 JUnit Platform 通过 Vintage Engine 支持 JUnit 4 测试 通过 Jupiter Engine 支持 JUnit 5 测试 4 个月内迁移超过 75,000 个测试类 修改超过 1,250,000 行代码 自动生成超过 5,000 个 diff这些数字说明这不是一个小范围试点而是真正的大规模框架迁移。更重要的是它不是简单把 import 改一改。Uber 要先改造测试基础设施让 Bazel 能通过 JUnit Platform 同时执行 JUnit 4 和 JUnit 5 测试。然后再用 OpenRewrite 做代码级迁移。最后还要用内部大规模代码变更工具 Shepherd 批量生成、验证和提交 diff。所以这次迁移可以拆成三层基础设施层让 Bazel 支持 JUnit Platform 代码改造层用 OpenRewrite 自动迁移测试代码 规模化执行层用 Shepherd 批量生成和验证 diff只有这三层都准备好迁移才能在 Uber 这种规模下完成。三、迁移前提先让 Bazel 支持 JUnit 5在真正改测试代码之前Uber 必须先解决一个基础问题代码库能不能运行 JUnit 5 测试Uber 使用 Bazel 作为主要构建系统但 Bazel 原生主要支持 JUnit 4而不是 JUnit Platform。JUnit 5 引入了一个重要抽象JUnit Platform。JUnit Platform 可以理解成一个统一测试执行平台。它上面可以挂不同 engine。其中Vintage Engine运行 JUnit 4 测试 Jupiter Engine运行 JUnit 5 测试这样一来同一个测试执行平台可以同时处理老测试和新测试。这对渐进式迁移非常关键。如果 Uber 必须一次性把所有测试从 JUnit 4 改成 JUnit 5风险会非常大。但有了 JUnit Platform 后JUnit 4 和 JUnit 5 可以共存老测试继续通过 Vintage Engine 运行 新测试通过 Jupiter Engine 运行 同一个 test runner 统一调度这就为增量迁移创造了条件。四、为什么需要自定义 test runner因为 Bazel 默认测试规则不能直接满足 Uber 的需求Uber 构建了一个自定义 test runner作为 Bazel test rule 和 JUnit Platform 之间的集成层。这个 test runner 不只是简单调用 JUnit Platform它还负责接入 Uber 内部测试体系中的一些逻辑例如Uber 特定的 test discovery execution listeners flaky test filters 额外测试初始化和环境设置 再委托给 JUnit Platform 执行也就是说Uber 不只是要“能跑 JUnit 5”还要让 JUnit 5 进入原有测试生态。如果跳过这些集成迁移后就可能丢失原来测试基础设施里的能力比如 flaky test 管控、执行监听、测试发现逻辑等。所以第一步不是改代码而是打通基础设施Bazel test rule ↓ Uber Custom Test Runner ↓ JUnit Platform ↓ Vintage Engine / Jupiter Engine ↓ JUnit 4 / JUnit 5 tests这样整个 monorepo 可以在同一套执行框架下同时支持新旧测试框架。五、为什么选择 OpenRewrite基础设施支持 JUnit 5 后下一步才是迁移代码。Uber 需要一个工具能够在超大规模代码库中自动、安全、可重复地重写 Java 测试代码。简单的文本替换不够。因为 JUnit 4 到 JUnit 5 的迁移不是简单字符串替换org.junit.Test - org.junit.jupiter.api.Test RunWith - ExtendWith 或其他结构 ExpectedException - assertThrows TemporaryFolder - TempDir Rule - Extension Parameterized runner - JUnit Jupiter parameterized tests base class 可能要替换 custom runner 可能要迁移成 extension BUILD 依赖也要更新这些改动需要理解 Java 类型、注解、方法调用、导入、继承关系和测试框架语义。Uber 选择了 OpenRewrite。OpenRewrite 是一个开源代码重构框架适合做大规模、可重复、语义感知的代码迁移。它的核心概念包括Recipe一组搜索或改写规则 Visitor遍历语法树并执行修改的逻辑 LSTLossless Semantic Tree保留类型信息、格式和元数据的语义树OpenRewrite 会把源码解析成 LSTrecipe 通过 visitor 遍历 LST修改注解、导入、方法调用、类定义等结构最后再把修改后的 LST 输出回源码文件。它的价值在于理解语义而不是只做文本替换 保留格式和元数据 可以组合多个 recipe 可以产生可 review 的 patch 可以输出诊断数据这非常适合 JUnit 迁移这种框架级代码改造。六、Bazel 和 OpenRewrite 的集成难题OpenRewrite 要正确工作需要两个关键输入要分析和修改的源文件 用于类型解析的完整 compilation classpath在普通 Maven 或 Gradle 项目中OpenRewrite 有相对成熟的集成方式。但 Uber 的 Java monorepo 使用 Bazel。Bazel 的 target、依赖和 classpath 都由 Bazel 构建图管理。OpenRewrite 不能凭空知道某个 test target 的完整源文件和编译依赖。所以 Uber 构建了一个自定义 Bazel aspect。Bazel aspect 可以沿着 Bazel build graph 收集额外信息。Uber 的 aspect 在应用到 test target 时会做这些事收集该 test target 的源文件 生成用于类型解析的完整 compilation classpath 把源文件和 classpath 传给 Uber 的 OpenRewrite 集成层 调用 OpenRewrite parser 构建 LST 运行配置的 recipes 生成 patch 文件 生成 datatables 目录保存 recipe-specific diagnostics可以把它理解成Bazel Target ↓ Custom Bazel Aspect ↓ Source Files Classpath ↓ Uber OpenRewrite Tooling ↓ OpenRewrite Parser Recipes ↓ .patch datatables/这个集成非常关键。如果没有 Bazel aspectOpenRewrite 就无法获得足够类型信息也无法在 Uber monorepo 中稳定运行。七、patch 和 datatables 分别有什么用运行 OpenRewrite 后Uber 会得到两类输出。第一类是 patch 文件。例如test_main.patch它包含源代码改动比如替换 import、删除RunWith、改成 JUnit Jupiter 注解等。patch 的好处是可以 review、apply、回滚也符合大规模代码变更流程。第二类是 datatables 目录。里面是 CSV 文件保存迁移过程中的元数据和诊断信息。原文提到的文件包括com.uber.openrewrite.table.DependencyUsage.csv org.openrewrite.table.SourcesFileResults.csv org.openrewrite.table.RecipeRunStats.csv这些数据不是附属品而是迁移自动化里很重要的部分。比如DependencyUsage.csv可以告诉迁移后新增了哪些测试依赖org.junit.jupiter org.mockito.junit.jupiter 其他 JUnit 5 或 Mockito 相关依赖然后自动化脚本就可以据此修改 Bazel BUILD 配置。也就是说OpenRewrite 不只负责改 Java 文件还输出足够信息帮助后续步骤自动更新构建依赖。八、迁移不能“能改就改”必须避免半迁移理想情况下Uber 希望一个 test target 里的所有测试文件都能迁移到 JUnit 5。但现实中并不是所有文件都能自动迁移。一些测试类会使用复杂模式比如特殊 test runner 自定义 rule 复杂 base test class 内部测试工具 不常见 JUnit 4 组合 无法安全映射到 JUnit 5 的模式如果强行迁移这些文件可能会出现最糟糕的情况文件被迁移一半 代码中混用 JUnit 4 和 JUnit 5 API 构建失败 测试行为变化 开发者需要手工修一堆边角问题但另一方面如果因为一个文件不支持就放弃整个 target 的迁移覆盖率又会很低。所以 Uber 采用了 OpenRewrite preconditions。九、Precondition文件级过滤确保只迁移可支持模式Precondition recipe 可以理解成迁移前的筛选器。它会逐个检查 test target 中的测试文件判断这个文件是否适合迁移。Uber 根据自己已经支持的迁移能力整理了一组可支持的测试工具列表包括支持的 test runners 支持的 test rules 支持的 base test classes迁移前precondition 会检查测试文件是否只使用了这些可支持模式。如果文件中包含未支持的 runner、rule 或 base class就将其排除不做迁移。这样做有两个好处。第一避免错误迁移。只有能处理完整语义的文件才会进入迁移流程。第二提高整体覆盖率。一个 target 中不支持的文件可以跳过其他可迁移文件仍然可以处理而不是整个 target 放弃。这是一种很务实的规模化迁移策略不追求一次性迁移所有代码而是先安全迁移确定能处理的部分再逐步扩展支持范围。十、OpenRewrite 官方 recipe 是基础但不够覆盖 Uber 的代码库OpenRewrite 自带 JUnit 4 到 JUnit 5 的迁移 recipeorg.openrewrite.java.testing.junit5.JUnit4to5Migration它包含许多常见迁移逻辑。例如ParameterizedRunnerToParameterized 处理 RunWith(Parameterized.class)迁移到 JUnit Jupiter parameterized tests ExpectedExceptionToAssertThrows 把 org.junit.rules.ExpectedException 迁移为 Assertions.assertThrows()这些官方 recipe 覆盖了很多标准 JUnit 4 用法。但 Uber 的代码库中有大量内部测试约定和自定义工具。例如内部 custom runner 内部 custom rule 内部 base test class 内部测试容器 特定环境变量 rule 内部数据库测试组件这些不是 OpenRewrite 官方 recipe 能天然支持的。所以 Uber 以 OpenRewrite 官方 JUnit4to5Migration recipe 为基础又编写了自己的定制 recipe处理 Uber 特有的测试模式。这也是大规模代码迁移经常遇到的情况开源工具能解决通用问题但企业内部代码库的历史约定和自定义框架需要自己补齐迁移逻辑。十一、如何决定先支持哪些自定义模式Uber 没有凭感觉写 recipe。他们先用 OpenRewrite 的 search recipe 统计 Java monorepo 中各种测试工具的使用频率。比如统计哪些 JUnit 4 test rule 用得最多 哪些 runner 用得最多 哪些 base test class 用得最多 哪些内部测试容器出现频率最高原文给了一个示例统计表org.junit.rules.ExpectedException751 个文件 org.junit.rules.TemporaryFolder346 个文件 com.uber.fievel.testing.dslite.UberDSLiteContainer264 个文件 com.uber.fievel.testing.mysql.UberMariaDBContainer162 个文件这类数据非常重要。因为迁移 recipe 的开发成本不是零。如果某个自定义 rule 只出现 2 次可能不值得立刻自动化。如果某个 rule 出现几百次就非常值得写迁移逻辑。Uber 的策略是先找最高频模式 为这些模式寻找或实现 JUnit 5 等价能力 再写 OpenRewrite recipe 自动迁移 用它们提高整体迁移覆盖率这是一种典型的 80/20 工程方法。先支持最常见的模式快速迁移大量代码。剩下低频、复杂模式可以后续处理或保留 JUnit 4。十二、JUnit 4 工具如何迁移到 JUnit 5对每种常见 JUnit 4 工具Uber 会做三类判断。第一类开源工具已有 JUnit 5 等价物如果某个 JUnit 4 rule 或 runner 在开源生态中已经有 JUnit 5 extension就直接迁移到对应 extension。例如某些临时文件、参数化测试、Mockito 集成等场景都可能已有成熟替代方案。第二类内部工具需要开发 JUnit 5 等价物如果 Uber 内部有自定义 JUnit 4 rule 或 runner但没有现成 JUnit 5 对应实现就需要先开发 JUnit 5 extension复制原功能。比如某些内部数据库容器、环境变量处理、上下文初始化逻辑都可能需要自定义 JUnit 5 extension。第三类不再需要或可以改成不同模式有些旧工具可能已经过时。迁移时可以删除它们或者换成更现代、更简单的写法。这一步非常关键。框架迁移不是把旧模式原封不动搬到新框架里。有些时候应该顺便清理旧工具。十三、完整 JUnit4to5Migration recipe 的阶段Uber 最终组装出一个完整迁移 recipecom.uber.openrewrite.recipe.junit.JUnit4to5Migration它不是单个规则而是一组 recipe 的组合。执行阶段大致如下1. 执行 precondition找出符合迁移条件的测试文件 2. 应用 OpenRewrite 官方 JUnit4to5Migration处理通用 JUnit 4 迁移场景 3. 转换 Uber 自定义 test runners 到 JUnit 5 extensions 4. 处理 Uber 自定义 test rules 5. 替换 legacy base test classes 为 JUnit 5 等价类或移除不再需要的 base class 6. 运行 FindDependencyUsage识别迁移后引入的新测试依赖这里有一个很重要的后置步骤FindDependencyUsage。代码迁移后源码 import 变了测试依赖也要变。例如迁移后可能新增JUnit Jupiter API JUnit Jupiter Params Mockito JUnit Jupiter 其他 extension 依赖如果只改 Java 代码不改 Bazel BUILD 文件测试会编译失败。所以 recipe 必须输出依赖使用信息再由后续脚本自动更新构建配置。这就是框架迁移和普通代码格式化的区别它不仅要修改源码还要修改构建图。十四、单个 test target 的端到端迁移流程有了完整 recipe 后Uber 还需要一个端到端迁移脚本。这个脚本输入非常简单一个 Bazel test target它会自动执行整套流程。可以整理成1. 对 test target 运行 OpenRewrite tooling 2. 生成 test_main.patch 3. 生成 DependencyUsage.csv 等 datatables 4. 应用 patch更新 Java 源文件 5. 根据 DependencyUsage.csv 更新 Bazel BUILD 配置 6. 构建更新后的 test target 7. 运行测试 8. 如果构建失败报告失败 9. 如果测试失败报告失败 test classes 10. 如果所有测试通过保留迁移结果这一步非常关键。迁移不能只看“代码被改了”。必须验证代码能编译 测试能运行 测试结果仍然通过 构建依赖正确否则自动化迁移只会制造大量损坏代码。Uber 的脚本确保只有构建和测试都通过迁移才算成功。这保证了迁移过程中测试套件始终保持可工作状态。十五、为什么需要 Shepherd上面的脚本可以迁移一个 Bazel test target。但 Uber 有成千上万个 test target。在本地逐个跑不现实。耗时会很长也很难生成、验证和管理大量 diff。所以 Uber 使用了内部工具 Shepherd。Shepherd 是一个用于执行大规模代码变更的工具。它接收一组 test targets 一个迁移脚本然后批量执行迁移找出成功迁移的 target并自动生成 diff。这些 diff 还会进入 Uber 的 CI 系统验证。CI 不只跑单元测试还会检查integration tests linter checks code coverage enforcement 其他质量门禁这对大规模迁移很重要。单个 target 的测试通过不代表整个代码库没有问题。CI 能提供更全面的质量保障。所以 Uber 的规模化迁移可以理解成OpenRewrite 负责代码变换 迁移脚本负责单 target 自动化 Shepherd 负责大规模批量执行 CI 负责质量验证十六、为什么不能一次性迁移所有 targetUber 的第一次自动迁移 rollout 生成了超过 2000 个 diff。但并不是所有 target 都迁移成功。失败原因包括构建失败 测试失败 使用了不支持的 JUnit 4 特性 使用了复杂测试模式 OpenRewrite recipe 未覆盖某些边界情况 迁移后依赖缺失 迁移后测试行为变化在这种规模下逐个诊断每个失败测试是不现实的。所以 Uber 采用增量 rollout。流程是1. 用当前 recipe 运行一轮自动迁移 2. 收集失败日志 3. 分析失败中的常见模式 4. 为常见 build failure 编写新的 OpenRewrite recipe 5. 对测试失败的文件如果诊断成本太高就回退到 JUnit 4 6. 重新运行下一轮迁移 7. 覆盖率逐步提高这形成一个反馈循环自动迁移 ↓ 失败分析 ↓ 补充 recipe ↓ 再次自动迁移 ↓ 生成更多成功 diff最终Uber 通过这种方式迁移了超过 75,000 个测试类修改近 125 万行代码。这说明大规模迁移的关键不是一次完美而是可迭代。十七、为什么测试失败直接回退而不是逐个修原文提到对于测试失败Uber 会将受影响文件回退到 JUnit 4。这是一个很现实的决策。在大规模自动迁移中如果某个测试迁移后失败可能有很多原因JUnit 4 和 JUnit 5 生命周期差异 Rule 和 Extension 行为差异 参数化测试顺序差异 异常断言语义变化 base class 初始化顺序变化 测试本身依赖某些隐式行为 迁移 recipe 处理不完整逐个分析这些失败非常耗时。如果一个失败只影响少量文件而团队目标是迁移绝大多数测试那么保留这些文件为 JUnit 4 更划算。由于 JUnit Platform 已经同时支持 Vintage 和 Jupiter两种测试可以共存。这就让 Uber 可以先迁移确定安全的大部分把边角复杂情况留到后面。这体现了一个重要原则大规模迁移不要为了追求 100% 自动覆盖而牺牲整体推进速度和稳定性。十八、这次迁移为什么是“技术 文化”挑战原文一开始提到这不只是技术挑战也是文化挑战。原因很简单。如果完全依赖业务团队自发迁移会遇到几个问题业务团队优先级不一致 很多团队没有动力主动迁移测试框架 开发者需要学习 JUnit 5 API 迁移会占用业务开发时间 不同团队迁移质量不一致 迁移周期可能拖很久集中式自动迁移的价值在于尽量减少业务团队负担 用统一工具和规则保证质量 用 CI 验证迁移结果 通过 diff 形式让代码 review 可控 让开发者不必手动学习所有迁移细节这就是大型组织做技术现代化常见的方式。不是靠发一个文档让所有团队自己改而是平台团队提供自动化工具、批量迁移、集中推进同时尽量减少业务团队的心智负担。十九、生成式 AI 为什么没有成为主力原文特别提到Uber 尝试过使用生成式 AI 一次迁移多个测试类文件但效果不好。最终AI 的使用范围被限制在 debugging test 和 build failures 上。原因也很容易理解。JUnit 迁移需要确定性 可重复 类型安全 大规模批量执行 能生成可 review patch 能和 Bazel classpath 集成 能输出依赖诊断信息 能保证不改变测试语义生成式 AI 在这类任务上有几个问题输出不够确定 不同运行结果可能不同 可能生成看似正确但语义错误的代码 难以保证所有边界情况 难以和构建图、classpath、依赖更新深度集成 难以在数万文件上稳定批量执行相比之下OpenRewrite 是 deterministic approach。它基于 AST/LST 和类型信息执行规则行为可预测、可复现、可组合也更容易在 CI 中验证。这并不是说 AI 没用。Uber 仍然把 AI 用于调试失败帮助理解 build 或 test failure。但在“自动修改数百万行代码”的核心环节确定性工具更适合。这点非常值得借鉴生成式 AI 适合辅助分析和调试但大规模代码库迁移的主流程仍然需要确定性、可验证、可重复的工程工具。二十、这次迁移的关键经验原文总结了几条经验可以展开理解。第一先打基础再迁移代码在迁移测试代码前Uber 先让所有测试模块接入 JUnit Platform。如果没有这一步迁移就无从谈起。因为 JUnit 4 和 JUnit 5 无法共存或者 Bazel 无法正确运行 JUnit 5 测试代码迁移就会变成高风险大爆炸式改造。所以第一条经验是框架迁移前先升级基础设施让新旧框架能够共存。这对任何大规模迁移都适用。例如从旧 RPC 框架迁到新 RPC 框架 从旧日志库迁到新日志库 从旧时间库迁到 java.time 从旧测试框架迁到新测试框架只要能让新旧共存就能增量推进风险会小很多。第二工具要战略性使用OpenRewrite 很强但不能把它当魔法。Uber 的做法是先统计最常见测试模式 优先支持高频 runner/rule/base class 把官方 recipe 和自定义 recipe 组合起来 用 precondition 过滤不安全文件 用 datatables 驱动构建依赖更新也就是说工具本身只是基础。真正决定迁移效果的是如何围绕工具建立策略。如果直接拿官方 JUnit4to5Migration recipe 在全库跑很可能覆盖率不够失败很多结果不可控。Uber 的经验是大规模自动迁移需要先理解自己代码库的模式再针对最高价值模式写迁移逻辑。第三确定性方法比 AI 更适合主流程AI 可以帮助分析失败但不适合作为核心迁移引擎。在这种规模下Uber 需要的是确定性 可重复 可验证 可审计 可回滚OpenRewrite 更符合这些要求。这也是很多企业级代码改造的现实选择。第四失败反馈循环很重要一次 rollout 不可能覆盖所有情况。必须接受第一次会失败很多 失败日志里有模式 提取共同模式 补 recipe 再跑一次 覆盖率提高这和编译器、lint、静态分析工具的迭代很像。真正成熟的迁移体系不是一次性脚本而是一个 feedback loop。第五大规模迁移必须依赖 CI自动迁移的结果必须被 CI 验证。否则 5000 多个 diff 可能会把代码库变成不可维护状态。Uber 不只跑单元测试还用 integration tests、linter、coverage 等门禁确保迁移质量。这说明自动化修改代码只是第一步自动化验证同样重要。二十一、未来工作把模式复用到更多现代化项目完成 JUnit 4 到 JUnit 5 迁移后Uber 对 OpenRewrite 大规模迁移方法更有信心。原文提到他们正在推进或计划推进的方向包括Spring Boot 3 service builds Guava 到标准 Java API Joda-Time 到 java.time其中 Spring Boot 3 的方向很有意思。Uber 构建了工具把 OpenRewrite actions 直接集成到 Bazel rule 中自动将 OpenRewrite 逻辑应用到 service dependency builds。这说明 OpenRewrite 不只是 JUnit 迁移一次性工具而可能成为 Uber Java 代码库现代化的基础设施。Guava 到标准 Java API、Joda-Time 到 java.time 也是典型大规模迁移。这些迁移和 JUnit 类似都有几个共同点大量历史代码 手工迁移成本高 存在常见模式 可以用自动化 recipe 改写 需要类型信息 需要构建验证 需要增量 rollout所以 JUnit 迁移建立的流程可以复用统计使用模式 写或组合 recipe 通过 Bazel aspect 获取源码和 classpath 生成 patch 和诊断数据 自动更新构建配置 批量生成 diff CI 验证 失败分析 迭代 recipe这就是这次迁移最长期的价值。它不只是把测试框架升级了还建立了一套未来大型技术迁移的方法论。二十二、从后端工程角度看这篇文章真正讲了什么表面上看这篇文章讲的是 JUnit 4 到 JUnit 5。但更深层它讲的是超大规模代码库如何安全、自动、增量地完成框架迁移。框架迁移难点不在改几行代码而在这些问题迁移范围太大 代码模式太多 构建系统复杂 测试基础设施有历史包袱 业务团队没有时间手动迁移 一次性迁移风险太高 失败模式多且长尾 构建配置也要同步修改 迁移后行为必须保持一致Uber 的方案是典型平台工程思路先改基础设施 再做确定性自动改写 再把自动改写接入构建系统 再用脚本封装端到端流程 再用大规模代码变更工具批量执行 再用 CI 验证 再通过反馈循环扩大覆盖率这比“写个脚本替换 import”复杂得多也可靠得多。二十三、和普通团队有什么关系你可能会觉得Uber 的规模太大普通团队用不上这么复杂的体系。但这篇文章的很多经验对普通团队同样适用。1. 迁移前先让新旧系统共存不要一上来全量切换。比如新旧测试框架共存 新旧 RPC client 共存 新旧数据库访问层共存 新旧日志库共存共存能力是增量迁移的基础。2. 不要靠纯文本替换做语义迁移如果迁移涉及类型、注解、方法调用、依赖关系应该使用语义感知工具。Java 里可以考虑 OpenRewrite。其他语言也有类似 AST-based refactoring 工具。3. 先统计再写规则不要凭直觉支持迁移模式。先统计代码库中最常见用法再优先支持高频模式迁移收益最大。4. 自动化迁移必须自动化验证迁移完成后必须跑构建和测试。否则自动化只是自动制造风险。5. 长尾问题可以暂时跳过不要为了覆盖最后 5% 的复杂模式拖慢前 80% 的迁移。先安全迁移大部分再处理边缘场景。6. 生成式 AI 可以辅助但不要盲目依赖AI 很适合解释错误、辅助调试、生成候选代码。但对大规模、确定性、可重复的代码迁移规则化工具仍然更可靠。7. 一次迁移沉淀一套流程如果你已经为 JUnit 迁移建立了自动化流程未来 Spring、Guava、时间库、日志库迁移都可以复用。真正的收益不是一次迁移而是一套迁移能力。二十四、我的理解这是一篇关于“代码库治理”的文章我觉得这篇文章最有价值的地方不是 JUnit 5 本身。而是它展示了一个大型公司如何治理历史代码库。历史代码库的问题不是“旧”而是旧框架继续可用但阻碍未来能力 旧 API 到处散落手动替换不现实 每个业务团队都忙没人愿意主动清理 长期不迁移未来升级成本越来越高如果没有平台能力这类问题会不断堆积。最后代码库会变成旧测试框架 旧时间库 旧集合库 旧 RPC 库 旧构建规则 旧 base class 旧内部工具每一个都不一定立即致命但它们会共同拖慢工程效率。Uber 的 JUnit 迁移说明代码库现代化不能只靠号召。它需要基础设施兼容层 自动改写工具 构建系统集成 迁移编排脚本 批量代码变更工具 CI 质量门禁 失败反馈循环这就是代码库治理的工程化。二十五、总结Uber 这篇文章讲述了他们如何在超大规模 Java monorepo 中完成 JUnit 4 到 JUnit 5 的迁移。迁移背景是Uber 的 Java monorepo 曾以 JUnit 4 作为主要测试框架但 JUnit 4 已停止活跃开发而 JUnit 5 已经发布多年提供了更现代的测试能力。继续依赖 JUnit 4 会阻碍代码库采用新测试能力也会带来长期维护风险。但迁移规模巨大超过 600,000 个 JUnit 4 测试 约 15,000,000 行代码 大量自定义 runner、rule、base test class 和内部测试工具 Bazel 原生不支持 JUnit PlatformUber 最终用集中式自动化迁移取得了这些结果让所有测试模块接入 JUnit Platform 通过 Vintage Engine 支持 JUnit 4 测试 通过 Jupiter Engine 支持 JUnit 5 测试 4 个月内迁移超过 75,000 个测试类 修改超过 1,250,000 行代码 自动生成超过 5,000 个 diff迁移分为几层。第一先改造测试基础设施。Uber 构建自定义 test runner把 Bazel test rule 和 JUnit Platform 打通并接入 Uber 内部 test discovery、execution listeners、flaky test filters 等能力。第二使用 OpenRewrite 做语义化代码改造。OpenRewrite 通过 LST、Recipe、Visitor 对 Java 源码进行类型安全、格式保留的自动重写。Uber 又通过自定义 Bazel aspect为 OpenRewrite 提供 test target 的源文件和完整 compilation classpath并生成 patch 与 datatables。第三组合官方和自定义迁移 recipe。Uber 以 OpenRewrite 官方 JUnit4to5Migration 为基础补充自定义 recipe处理内部 runner、rule、base class 等模式。为了最大化覆盖率他们先用 search recipes 统计最常见测试工具用法再优先支持高频模式。第四使用 precondition 避免错误迁移。precondition 会过滤出当前 recipe 能安全处理的测试文件跳过包含不支持工具的文件避免半迁移导致构建失败。第五封装端到端迁移脚本。脚本对单个 Bazel test target 执行迁移运行 OpenRewrite、生成 patch、应用源码改动、根据 DependencyUsage.csv 更新 BUILD 依赖、构建并运行测试。只有所有测试通过迁移结果才被保留。第六用 Shepherd 批量 rollout。Shepherd 是 Uber 内部大规模代码变更工具用于在数千个 test target 上执行迁移脚本生成 diff并通过 CI 验证 integration tests、lint、coverage 等门禁。第七采用增量反馈循环。第一次 rollout 无法迁移所有 target。Uber 会分析失败日志识别常见 build failure 模式补充 OpenRewrite recipe再次 rollout。对于复杂 test failure则将相关文件回退到 JUnit 4以保证迁移稳定推进。最终这次迁移不只是一次测试框架升级也为 Uber 建立了一套可复用的大规模代码现代化流程。未来他们计划将类似方法用于 Spring Boot 3、Guava 到标准 Java API、Joda-Time 到 java.time 等迁移。这篇文章最大的启发是大规模代码库迁移不能依赖人工号召也不能依赖简单文本替换。它需要基础设施兼容、语义化重构工具、构建系统集成、端到端验证、大规模 rollout 和失败反馈循环。对于任何有大型代码库的团队来说这都是一套非常值得借鉴的迁移方法论。参考资料How Uber Executed A JUnit Migration at Massive Scale