Uber’s migration of more than 75,000 test classes from JUnit 4 to JUnit 5 is not just a library upgrade. It is a case study in how large teams clean up old...
Uber Migrates 75,000+ Test Classes from JUnit 4 to JUnit 5: What Really Works in Large-Scale Java Test Modernization
Uber’s migration of more than 75,000 test classes from JUnit 4 to JUnit 5 is not just a library upgrade. It is a case study in how large teams clean up old test code without wrecking build speed, developer trust, or release flow. I have seen this kind of mess before, and it usually starts with good intentions, then turns into a swamp of brittle test patterns, hidden dependencies, and fear of touching anything that still passes. Leela Kumili’s report about Uber’s automated code transformation points to the real lesson: at scale, migration succeeds when you treat tests as production assets, not throwaway code. —Ermenildo V. Castro, Jr.
Key Takeaways:
- Large JUnit 4 to JUnit 5 migrations are mostly about automation, consistency, and safety.
- The hard part is not syntax. It is behavioral drift, custom runners, rules, and old assumptions.
- Automated code transformation helps teams move fast, but only when paired with strong validation.
- Test modernization is cheaper when you standardize patterns early.
- Clean tests are stewardship. Messy tests waste human effort, and that is bad engineering and bad order.
What is the Uber JUnit 4 to JUnit 5 migration?
Uber’s migration is a large-scale move from JUnit 4, the older Java testing framework, to JUnit 5, the newer platform made of JUnit Platform, JUnit Jupiter, and JUnit Vintage. The key detail is scale. We are not talking about a few dozen tests in a hobby repo. We are talking about 75,000+ test classes changed with automated transformation so engineers could keep shipping while the codebase modernized.
That matters. A lot.
JUnit 5 is not a cosmetic rename. It changes how tests are written, organized, and extended. You get more flexible annotations, better extension support, cleaner parameterized tests, and a platform designed for newer Java code. The old JUnit 4 style still works in many places, but it carries baggage. Old runners. Old rules. Old habits. The usual stuff that clings to a codebase like gum under a desk.
I have lived through migrations like this more than once. At Google, Microsoft, Amazon, and later at Zulily, I saw the same pattern repeat: teams delay test upgrades because “the tests still pass,” then one day the framework is so old that every change becomes a small war. That is when technical debt stops being a theory and starts burning calendar time.
Uber’s approach was smart because it treated migration as an engineering problem, not a ceremony. They used automated code transformation to rewrite tests at scale, which is the only sane way to handle a codebase that large. Manual edits would take forever, and worse, humans would introduce uneven style, missed imports, and broken assumptions. A tool can be blunt, but blunt is fine if the target is repetitive work.
For background, the official JUnit team documents the platform shift clearly in the JUnit 5 User Guide, and the migration path is also supported by the JUnit Vintage engine. Those docs explain the framework split. Uber’s case explains the real-world pain.
If you want a broad engineering angle on migration work, see refactoring code at scale. It is the same lesson, just with fewer test fixtures and more regret.
Core benefits of moving to JUnit 5
The benefits are real. So are the tradeoffs.
1. Cleaner test structure
JUnit 5 gives you a more modern way to write tests, with @BeforeEach, @AfterEach, @Nested, and more useful extension points. That sounds small until you see a giant test suite where every test class looks like a junk drawer. Better structure cuts confusion fast.
2. Better extension model
JUnit 4 leaned heavily on runners and rules, which worked, but not always gracefully. JUnit 5’s extension model is more flexible. You can compose behavior more cleanly, and that matters when teams have custom test setup, mocking helpers, or environment hooks.
3. Stronger support for parameterized testing
JUnit 5 makes parameterized tests easier to use and easier to read. That means less copy-paste and fewer tiny test methods that all do the same thing with different data. I have seen teams cut huge blocks of duplicate code just by leaning on this one feature. It is boring. It also saves hours.
4. Better alignment with modern Java
Most teams do not stay on old Java forever. Test code should not pretend the world froze in 2014. JUnit 5 fits better with newer Java versions and newer build tooling. It is a practical move, not a fashionable one.
5. Lower long-term maintenance cost
This is the part people ignore.
Old test frameworks do not just age. They rot. They force special-case helpers, awkward imports, and odd exceptions that only one senior engineer remembers. That memory is not a strategy. Migration reduces the load on future developers, which is an act of stewardship if you want the plain truth.
6. Less fear around large changes
Once a team proves it can automate a move like this, it builds confidence for future cleanups. That is huge. The first migration is expensive. The second one is easier because the organization now trusts its tools and patterns.
When I worked on large systems, the best teams did not merely write tests. They respected them. Tests are not side chores. They are evidence. They protect users, and they protect the dignity of the engineer who has to maintain the system later. That is not fluff. That is how serious software work should be done.
If you are also thinking about test reliability and automation, this pairs well with practical guidance from the Test Pyramid and the broader idea of keeping higher-value tests focused on behavior instead of framework tricks.
For teams modernizing Java work, I also recommend reading Baeldung’s JUnit 5 guide. It is not perfect, but it is useful, and useful beats clever.
Step-by-step guide to a large JUnit migration
Big migrations fail when teams guess. They also fail when they pretend “find and replace” is architecture. Here is the cleaner path.
- Inventory the test surface
Start by measuring what you have. Count test classes. Find custom runners, rules, and base classes. Map out helper patterns. In any serious codebase, the weird stuff lives in the corners. That is where migrations go to die.
At Uber’s scale, the team had to understand the blast radius before rewriting anything. That is not optional. It is basic respect for the system.
- Separate simple tests from tricky tests
Not every test needs the same treatment. Many can be transformed with automation. Some cannot. The ones with custom runners, static state, or odd setup logic need special handling.
This is where people get lazy. They push one tool across everything and hope. Hope is not a plan.
- Build automated transformation rules
Use code transformation tools to rewrite annotations, imports, assertion styles, and setup methods. The goal is not elegance. The goal is repeatable correctness.
I have seen teams hand-edit a few hundred tests and then burn out. That is a trap. A good transformation pass gives you consistent output, and consistency is the whole game.
- Validate behavior after each batch
Do not trust green builds alone.
Run targeted suites. Compare failure rates. Watch for tests that pass for the wrong reason. A migration can break lifecycle behavior without obvious compile errors, especially when old rules or runners are involved.
This step is where engineering judgment matters. At Microsoft, I learned that a migration is only “done” when the system behaves the same way under the new model. Syntax is easy. Behavior is the hard part.
- Keep fallback paths short-lived
JUnit Vintage can help during transition, but it should not become a parking lot. Use it to bridge the gap, not to avoid the work. Every extra month of dual support adds noise and lowers urgency.
- Standardize the new style
Once the move is stable, update templates, code review checks, and internal docs. If you do not codify the new standard, people drift back to old habits. Humans love old habits. They are cheap and often wrong.
- Measure what changed
Track build time, failure rates, flaky tests, and developer time saved. If the migration is real, you should see evidence. If you cannot measure it, then it is just a story people tell in meetings.
For teams doing broader code cleanup, search-quality thinking is not the point, but disciplined measurement is. You need proof, not vibes.
Comparison table: JUnit 4 vs JUnit 5
Here is the plain comparison. No confetti.
| Feature | JUnit 4 | JUnit 5 |
| Core architecture | Single framework | Platform + Jupiter + Vintage |
| Test lifecycle | Simpler, older model | More flexible lifecycle annotations |
| Extensions | Runners and rules | Extension model, more composable |
| Parameterized tests | Available, clunkier | Cleaner and more usable |
| Modern Java fit | Older style | Better fit for newer Java |
| Migration support | N/A | Strong support for phased upgrades |
| Large-scale refactoring | Harder | Easier with automation |
| Long-term maintainability | Often weaker | Better for new work |
Biggest competitor: staying on JUnit 4
The main competitor is not another framework. It is inertia.
That is the ugly truth.
Keeping JUnit 4 may feel safe because the tests already run. But “already runs” is not the same as “healthy.” Old tests can hide brittle setup, unused helpers, and assumptions that future teams do not understand. And when Java versions, build plugins, or IDE support shift, the pain lands all at once.
JUnit 4 also makes it harder to use newer extension patterns cleanly. You can work around that, sure. Engineers can work around almost anything. We once used to normalize absurd amounts of glue just to keep old code alive. That does not mean it was wise.
The real competitor is delay. Delay costs more than migration when the codebase is large enough.
If you want the official baseline for transition concerns, the JUnit migration support docs are the best place to start. They are dry, but dry is fine when the stakes are high.
Common mistakes to avoid
Most migration advice is too cheerful. That is a problem. A big one.
1. Treating migration like a text edit
Nope. It is not that simple.
People think annotation swaps are the whole job. They are not. Custom test runners, rules, lifecycle hooks, and helper classes often carry real behavior. If you miss that, you get false confidence and hidden breakage.
2. Ignoring flaky tests before migration
If your suite is already flaky, a framework switch will not save you. It may expose the mess faster, which is useful, but it will still be a mess.
I have seen teams blame the migration when the real problem was old, unstable test data. That is like blaming a new roof for the leaky pipes.
3. Leaving old patterns in place too long
Dual support is fine for a while. Long-term dual support is a crutch. It spreads attention thin and makes code review harder.
Frankly, if you keep both systems forever, you have not migrated. You have just added baggage.
4. Skipping developer education
A tool can rewrite code. It cannot teach taste.
If engineers do not understand the new patterns, they will recreate the old ones in new clothes. That is how bad habits survive framework changes. Write a short internal guide. Show examples. Make the approved path obvious.
5. Forgetting the build pipeline
A test framework switch affects CI, plugins, reports, and sometimes IDE behavior. If you only test locally, you are not done. The pipeline is where real pain shows up.
When I see advice online, I often notice it stops at “use the new annotation.” That is shallow. Large systems need planning, validation, and patience. Good work respects the time of the next engineer. That is part of justice in software, whether people say it out loud or not.
For more on practical codebase change, this is also where workflow discipline helps, because migration without process turns into chaos with better fonts.
Frequently asked questions
Why did Uber use automated code transformation?
Because manual migration for tens of thousands of tests would be slow and inconsistent. Automation gives repeatable rewrites and frees engineers to handle the edge cases.
Is JUnit 5 backward compatible with JUnit 4 tests?
Partly. JUnit 5 includes the Vintage engine, which can run many JUnit 4 tests during transition. But that is a bridge, not the destination.
What is the hardest part of moving from JUnit 4 to JUnit 5?
The hardest part is not syntax. It is finding custom runners, rules, and fragile test assumptions that change behavior when the framework changes.
Should every team migrate right away?
Not blindly. If your test suite is tiny and stable, you may not need urgency. But if your Java codebase is large, actively maintained, or getting harder to update, waiting usually costs more later.
Final thought
Big test migrations are not glamorous.
They are disciplined work, and disciplined work is what keeps software honest. When I look at moves like Uber’s JUnit upgrade, I do not just see tooling. I see people trying to care for a large system without wasting the time of the next person in line. That matters. In my years across Google, Microsoft, Amazon, and Zulily, the healthiest teams always treated code as shared responsibility, not personal property.
That mindset fits a simple truth: work has dignity when it serves others well.
So yes, the details matter. The framework matters. The automation matters. But the deeper lesson is older than Java. Build things that last. Clean up what you can. Leave the place better than you found it. Ermenildo Castro would call that good engineering. I call it plain responsibility.