Staff Software Engineer
Software Engineer
Introduction
Earlier this year, Uber’s Java monorepo used JUnit 4 as the primary framework for its test suite. While functional, reliance on this framework prevented us from adopting the enhanced testing capabilities of JUnit 5, which launched over eight years ago. Moreover, active development on JUnit 4 ceased in 2021, meaning bugs, security patches, and features would no longer get addressed. There was a clear case for transitioning to a modernized test framework, but it came with challenges.
With over 600,000 JUnit 4 tests spanning 15 million lines of code, we faced both a technical and cultural challenge: motivating developers to adopt JUnit 5. A manual migration would require developers to learn the Jupiter API and migrate custom test suites, consuming valuable engineering hours. Additionally, Uber uses Bazel™, which lacks native JUnit 5 support. Future JUnit versions plan to drop JUnit 4 support entirely, making migration increasingly urgent.
Given these challenges, we needed a large-scale migration approach that’d automate the transition while minimally disrupting workflows.
Migration Outcomes
Our centralized migration was a success. Through our automated migration efforts, we achieved the following results:
- Enabled JUnit 5 support with Bazel, onboarding all test modules in our codebase to the JUnit Platform, supporting both JUnit 4 and JUnit 5 tests through the Vintage and Jupiter engines
- Migrated over 75,000 test classes to JUnit 5, modifying over 1.25 million lines of code within 4 months
- Generated over 5,000 diffs with automated code changes
This blog describes how we achieved these outcomes and our process for executing a careful, centralized migration.
Migration Prerequisites
Enabling JUnit 5 Support In Our Codebase
Before we could migrate or even write JUnit 5 tests, we first had to add support for it in our infrastructure. To enable this, we unified our test execution under the JUnit Platform, the foundational test framework introduced with JUnit 5. This platform acts as a universal runner, using the Vintage engine for legacy JUnit 4 tests and the Jupiter engine for JUnit 5 tests.
However, Bazel, our primary build system, natively supports JUnit 4, not the JUnit Platform. We addressed this by building a custom test runner that serves as an integration layer between our Bazel test rule and the JUnit Platform. Our test runner configures Uber-specific test discovery, execution listeners, flaky test filters, and additional setup, then delegates execution to the JUnit Platform for unified execution of both JUnit 4 and JUnit 5 tests.
Figure 1: Enabling JUnit 5 support for Bazel.
This compatibility allowed teams to begin using JUnit 5 and set the foundation for our centralized migration to happen.
Code Refactoring with OpenRewrite
The scale of this migration required a utility capable of deterministically transforming our test code without violating type safety or losing semantic meaning. This utility was Moderne's open-source OpenRewrite™ framework.
OpenRewrite provides recipes, which are search and refactoring operations applied to source code. It parses code into an LST (Lossless Semantic Tree), an in-memory structure containing type information, formatting, and metadata. Visitors, which encapsulate the recipe’s logic, traverse this LST and apply transformations to language constructs like method declarations, annotations, or imports. The modified LST is converted to plain text, overwriting the original source code. This workflow is visualized in Figure 2.
Figure 2: Basic OpenRewrite workflow.
Before using these capabilities, we first had to overcome a significant hurdle: the lack of OpenRewrite support with Bazel.
For this integration to work, OpenRewrite requires two key inputs: the source files to modify or analyze, and the compilation classpath for type resolution. To achieve this, we built a custom Bazel aspect. Our aspect performs the following functionality when applied to a test target:
- Collects the test target’s source files and generates the full compilation classpath for type resolution.
- Passes the above outputs to Uber’s integration layer around OpenRewrite, which:
- Invokes OpenRewrite’s parser to construct the LST.
- Runs the configured recipes on the parsed LST to apply transformations.
- Produces a patch file (containing the code transformations) and a directory called datatables, containing CSV files with recipe-specific diagnostics.
Figure 3: Custom aspect workflow.
The specific OpenRewrite recipe to apply is passed as an aspect parameter, as shown in Figure 4.
Figure 4: Applying a Bazel aspect to a build target.
This command generates the patch file and datatables directory described previously. Figure 5 shows an example of a patch file containing the code transformations.
Figure 5: Patch file generated by the bazel build command.
Migration Implementation
With the foundations in place, we began writing or leveraging existing OpenRewrite recipes needed for our migration. We assembled these recipes into a single comprehensive migration recipe: com.uber.openrewrite.recipe.junit.JUnit4to5Migration. The following sections cover our strategy for maximizing the migration’s scale, determining what custom recipes to write, and the end-to-end workflow for migrating a test target.
Maximizing Our Migration’s Scale
Ideally, our migration recipe should convert every file within a test target to JUnit 5. However, this isn’t always possible as our migration logic handles specific scenarios. Certain test classes use complex patterns like specialized test runners, rules, or base test classes that our logic may not resolve correctly. Forcing migration on these files would leave them partially migrated, mixing JUnit 4 and 5 APIs and causing build failures.
At the same time, we didn’t want to abandon an entire target’s migration because one file contained an unsupported case. Halting the process would significantly slow progress and increase manual work.
This is where OpenRewrite preconditions proved invaluable. Precondition recipes act as file-level filters that determine which files are suitable for processing. Based on migration recipes we wrote for common test utilities and those OpenRewrite natively supported, we compiled a list of supported test runners, rules, and base classes. Our precondition validated each test file against this list. Files containing any unsupported utility were excluded, ensuring we only transformed test targets we could handle completely.
Figure 6: Precondition filtering.
Assembling Our Code Transformation Logic
Our migration logic is encapsulated in a declarative recipe called com.uber.openrewrite.recipe.junit.JUnit4to5Migration, a composition of recipes that apply code transformations. This includes recipes from OpenRewrite, supplemented by custom recipes we authored to address cases specific to Uber’s test suites.
OpenRewrite provides its own migration recipe, org.openrewrite.java.testing.junit5.JUnit4to5Migration, which serves as the foundation for our code transformations. This recipe is composed of several independent recipes, including:
- org.openrewrite.java.testing.junit5.ParameterizedRunnerToParameterized: Handles the @RunWith(Parameterized.class) annotation by setting up JUnit Jupiter parameterized tests
- org.openrewrite.java.testing.junit5.ExpectedExceptionToAssertThrows: Changes any use of the org.junit.rules.ExpectedException JUnit 4 rule with Assertions.assertThrows()
OpenRewrite's recipes cover a wide range of common migration cases. Uber has also contributed upstream improvements to these recipes, enhancing their coverage and handling additional edge cases.
While OpenRewrite's recipes handle most standard JUnit 4 patterns, our codebase contains custom test conventions requiring additional logic. Using OpenRewrite's JUnit4to5Migration recipe as a foundation, we wrote tailored recipes to handle our custom JUnit 4 test rules, runners, and base test classes.
To maximize migration coverage, we prioritized writing recipes for the most commonly used JUnit 4 test utilities. We leveraged search recipes to gather usage metrics of all test runners, test rules, and base test classes in our Java monorepo.
Figure 7: Sample metrics gathered for test rule usage in Uber’s Java monorepo.
With these insights, we planned and executed the following approach:
- Identify or create JUnit 5 equivalents (when applicable).
- For open-source JUnit 4 test utilities, locate corresponding JUnit 5 extensions.
- For custom internal utilities, develop JUnit 5 equivalents replicating the same functionality.
- In some cases, implement alternative logic using different patterns or remove obsolete utilities entirely.
The Complete Migration Recipe
From here, we assembled our complete JUnit 4-to-5 migration recipe. The recipe executes in the following stages:
- Apply the precondition check to identify eligible test files for migration.
- Apply OpenRewrite's JUnit4to5Migration recipe, handling common migration cases.
- Convert custom test runners to JUnit 5 extensions and handle custom rule patterns.
- Swap out legacy base test classes with their JUnit 5 equivalents or remove them entirely.
- Run the FindDependencyUsage recipe on the transformed code to identify newly introduced test dependencies (such as JUnit Jupiter, Mockito JUnit Jupiter).
Figure 8 shows this simplified recipe declaration.
Figure 8: JUnit4to5Migration recipe declaration.
To apply this full migration recipe to a Bazel test target, we run the command shown in Figure 9.
Figure 9: Apply the JUnit4to5Migration recipe to a Bazel test target.
This produces the following outputs:
- Patch file: test_main.patch, which contains the code transformations to migrate the test from JUnit 4 to JUnit 5
- Datatable directory: datatable/, which contains CSV files with migration metadata and dependency information:
- com.uber.openrewrite.table.DependencyUsage.csv
- org.openrewrite.table.SourcesFileResults.csv
- org.openrewrite.table.RecipeRunStats.csv
Figure 10: JUnit4to5Migration recipe workflow.
Orchestrating the End-to-End Migration Process
While comprehensive, assembling the JUnit 4-to-5 migration recipe is just one step of the end-to-end workflow. To fully migrate a test target, we must orchestrate additional steps: applying the recipe’s outputs to update source files, modifying the build configuration with new dependencies, and running tests to ensure the migration was successful. This workflow is depicted in Figure 11.
Figure 11: End-to-end migration workflow.
The workflow from Figure 11 is encapsulated in a shell script that orchestrates the migration logic. The only input is the test target to migrate.
Through the command in Figure 12, we invoke our migration orchestration script.
Figure 12: Migration orchestration script.
The following process kicks off:
- Run OpenRewrite tooling: The script applies the full JUnit4to5Migration recipe on the test target, generating key migration artifacts, including:
- test_main.patch: Contains the code transformations
- DependencyUsage.csv: Usage of test dependencies introduced by the transformations (such as org.junit.jupiter., org.mockito.junit.jupiter.)
- Apply changes: The script applies the patch to update source files and reads DependencyUsage.csv to update the build configuration with necessary test dependencies.
- Validate migration: The script runs the updated test target to verify the migration succeeded.
- Handle results: If the build succeeds, the script runs the target's test suite. If tests fail, the migration ends and reports which test classes failed. If all tests pass, the migration is complete and all changes are preserved.
This automated workflow ensures migrations only proceed when all tests pass, maintaining a working test suite throughout the process.
Automated Migration Rollout
Executing Automated Code Changes with Shepherd
The workflow described above applies to a single target. However, we needed to scale across thousands of test targets in our codebase. Running this locally isn't practical, as it’d take several hours. To address this, we used Shepherd, an internal tool for executing large-scale code changes.
Shepherd identifies successfully migrated targets and automatically generates diffs for each one. These diffs are validated through Uber's CI system to adhere to criteria beyond unit tests, including integration tests, linter checks, and code coverage enforcement. Given the migration's scale, this validation is critical for maintaining confidence in our changes and ensuring migrated tests preserve their original behavior without introducing regressions or altering outcomes.
Figure 13: Automated diff generation through Shepherd.
Incremental Rollouts
The initial migration rollout generated over 2,000 diffs. However, not all test targets were successfully migrated in this first pass due to build or test failures, or unsupported test features and patterns.
To address this, we adopted an incremental approach: run an automated migration rollout through Shepherd, analyze failures from the logs, add support for missing patterns, and rerun the rollout. For test failures, we reverted affected files back to JUnit 4. Given the scale spanning thousands of targets, diagnosing individual test failures was impractical. For build failures, we identified common patterns and implemented additional OpenRewrite recipes to resolve them. This iterative process progressively increased coverage while minimizing manual intervention, ultimately migrating over 75,000 test classes and modifying nearly 1.25 million lines of code.
Figure 14: Migration development feedback loop.
Conclusion
Migrations of this scale require careful planning and execution. In our case, this wasn’t a simple dependency upgrade, but a framework migration that needed a methodical approach.
Key Learnings
Establishing a Foundation
Before migrating test code, we onboarded every test module to the JUnit Platform, which required revisions to our build and test infrastructure. Without this foundation, the migration wouldn’t have been possible.
Strategic Use of Tooling
OpenRewrite was instrumental, but strategically leveraging it to maximize coverage was important. We designed our migration logic around the most commonly used test runners, rules, and base classes. By focusing on high-impact patterns, we achieved broad coverage efficiently.
Use of Generative AI
We attempted to use generative AI to migrate multiple test class files at once, but this approach was unsuccessful. AI usage was limited to debugging test and build failures. Given our codebase's scale and custom testing patterns, a deterministic approach proved more practical than an AI-driven migration.
Future Work
The successful execution of our JUnit 4-to-5 migration gave us confidence in executing large-scale migrations using OpenRewrite. We're currently pursuing or plan to pursue modernization efforts including:
- For Spring Boot 3 service builds, we built tooling to integrate OpenRewrite actions directly into a Bazel rule, automatically applying OpenRewrite logic to a service's dependency builds.
- Guava to Standard Java API
- Joda-Time to java.time
Ultimately, this migration has saved countless engineering hours and established a clear procedure for executing large-scale technical changes. The patterns, tooling, and processes we developed can be applied to future framework and dependency upgrades, keeping our codebase modern and maintainable.
Acknowledgments
Cover Photo Attribution: “Don Valley Park and Lower Don River Trail, Toronto, Canada” by jimfeng Getty is under a Royalty-Free license.
Bazel™ is a trademark of Google LLC. No endorsement by Google LLC is implied by the use of this mark.
gRPC™ is a trademark of The Linux Foundation. No endorsement by The Linux Foundation is implied by the use of this mark.
Java, MySQL, and NetSuite are registered trademarks of Oracle® and/or its affiliates.
JUnit and the JUnit logo are trademarks of the JUnit Team. No endorsement by the JUnit Team is implied by the use of these marks.
Moderne™ is a trademark of Moderne, Inc. No endorsement by Moderne, Inc. is implied by the use of this mark.
OpenRewrite™ is a trademark of Moderne, Inc. No endorsement by Moderne, Inc. is implied by the use of this mark.
Spring Boot™ is a trademark of Broadcom Inc. and/or its subsidiaries. No endorsement by Broadcom is implied by the use of this mark.
Stay up to date with the latest from Uber Engineering—follow us on LinkedIn for our newest blog posts and insights.
Produits
Entreprise