The migration from JUnit 4 to JUnit 5 became an architectural-scale task. The key is automation and deterministic code transformations.
The problem does not manifest immediately — until the stack begins to limit development. In this case, JUnit 4 was in maintenance mode, which meant a lack of new features and an increase in technical debt. For a large Java monorepo, this turns into a systemic limitation: new testing patterns are unavailable, while maintaining the old ones requires increasing effort. The scale exacerbates the situation — over 75,000 test classes and 1.25 million lines of code, tied to Bazel, which initially does not support JUnit 5. A simple framework replacement here breaks pipelines and reduces release velocity.
The solution turned out to be a compromise and phased. Instead of a “big bang” migration, compatibility through the JUnit Platform was chosen. This allowed running JUnit 4 and JUnit 5 tests in parallel using the Vintage and Jupiter engines. This approach reduces risk: the system continues to operate while the new model gradually replaces the old one. The trade-off is clear — a temporary increase in execution layer complexity and operational overhead. But this is the price for continuous development and the absence of downtime.
A key element became automation through OpenRewrite. Unlike generative AI, which showed unstable results on custom tests, a deterministic approach was used here. OpenRewrite works with the semantic tree of the code (AST), allowing for precise and predictable transformations of the JUnit 4 API to JUnit 5. Engineers described transformation recipes: updating annotations, replacing legacy rules, converting parameterized tests to a Jupiter-compatible format. For internal patterns, custom rules were added, including support for proprietary test runners and base classes.
To avoid partial and inconsistent changes, precondition checks were added. They filter out unsupported cases and ensure that a file is either fully migrated or untouched. This reduces the risk of “broken” tests and simplifies debugging. Additionally, the frequency of usage of constructs was analyzed to first cover the most common patterns and enhance automation efficiency.
Orchestration at this scale is a separate task. An internal tool, Shepherd, managed transformations across thousands of Bazel targets in parallel. It generated diffs and ran them through CI, including unit and integration tests. This created a closed-loop verification cycle: any change was accepted only if behavioral correctness was maintained. Such a pipeline effectively turns migration into a managed flow of changes rather than a one-off operation.
The results cannot be assessed through classic performance metrics — they are not provided in the original data. But the architectural effect is clear. The system received a modern testing stack with a modular architecture and better extensibility. At the same time, it managed to avoid halting development and manual rework of hundreds of thousands of tests. Additionally, a foundation was laid for future transformations: migrations to Spring Boot 3 are already being considered, along with a transition from Guava to standard APIs and a shift from Joda-Time to java.time.
The main takeaway is that in tasks of this scale, it is not the tool that matters, but the properties of the process. Determinism, compatibility, and phased implementation prove to be more important than speed. Generative approaches currently fall short where predictability is critical. And a monorepo with tight integration into CI/CD requires not just a migration script, but a comprehensive change architecture.