You inherit a large codebase with thousands of JUnit 4 tests. How do you adopt JUnit 5 incrementally, and what are the trade-offs of the Vintage bridge?
answer
- Add junit-jupiter + junit-vintage-engine
- all NEW tests in Jupiter, migrate old opportunistically
- Vintage runs tests, not custom runners/Rules
- OpenRewrite for mechanical renames
- exit = drop Vintage + junit:junit when count hits 0
basics
~20 sAdd the JUnit 5 dependencies including the Vintage engine, so old JUnit 4 tests keep running while you write all new tests in JUnit 5. Then migrate old tests gradually, removing Vintage once they're all converted.
solid answer
~50 sThe whole point of the three-engine Platform is to avoid a big-bang rewrite. You add junit-jupiter (api + engine) for new tests and keep junit-vintage-engine on the test classpath so the existing JUnit 4 tests run unchanged on the same Platform, in the same Gradle/Maven run. New tests are written in Jupiter from day one; old ones are migrated opportunistically — touched files first, or with tooling like OpenRewrite for the mechanical renames. The trade-offs: Vintage runs JUnit 4 *tests* but does not translate JUnit 4 *extensions* — custom @RunWith runners and some Rules won't map automatically, so heavily-customized tests need real rework. You also carry two engines and two API styles, which can confuse contributors and slightly slow discovery. The exit criterion is: all tests on Jupiter, drop Vintage and the JUnit 4 dependency. Treat it as a deliberate, time-boxed migration, not a permanent state.
go deeper
Know that adding the Vintage engine lets old JUnit 4 tests keep running alongside new JUnit 5 tests.
Lay out the steps: add jupiter + vintage deps, write new tests in Jupiter, migrate old gradually, use useJUnitPlatform().
Weigh the trade-offs — Vintage runs tests not extensions, two API styles, tooling like OpenRewrite, and define an exit criterion.
Own it as a governed, time-boxed migration with a burn-down metric, triage of custom runners/Rules into extensions, and risk control over machine-converted diffs and lingering JUnit 4 CVE surface.
## The situation A *unit test* asserts a piece of code behaves correctly; a large app may have **thousands** written against **JUnit 4**. You want JUnit 5's benefits (extensions, parameter injection, better assertions, parameterized tests, nested tests) without halting feature work to rewrite everything at once. ## Why incremental adoption is even possible Recall JUnit 5's architecture: a **Platform** (the launcher + the `TestEngine` plug-in contract) with pluggable **engines**. **Jupiter** is the engine for new-style tests; **Vintage** is an engine that runs **JUnit 3/4** tests *as they are*. Because both engines run on the *same* Platform, a single build run can execute old and new tests together. This is the mechanism that makes a phased migration realistic. ## The concrete plan 1. **Add dependencies.** Put `junit-jupiter` (api + engine) on the test classpath for new tests, and `junit-vintage-engine` so the existing JUnit 4 tests still run. Ensure the build tool uses the JUnit Platform (`useJUnitPlatform()` in Gradle; recent Surefire/Failsafe in Maven). The old `junit:junit` 4 jar stays only because Vintage and the legacy tests still compile against it. 2. **Stop the bleeding.** Mandate that *all new* tests are written in Jupiter. This caps the JUnit 4 surface and starts shrinking its relative share immediately. 3. **Migrate opportunistically.** Convert JUnit 4 classes when you already touch them (the 'boy-scout' rule), or run a focused effort with **automated rewriting** (e.g. OpenRewrite's JUnit 4→5 recipe) that handles the mechanical parts: import swaps, annotation renames, `assertThat`/message-argument reordering, `expected`→`assertThrows`. 4. **Handle the hard cases by hand.** Custom `@RunWith` runners and non-trivial `@Rule`s have no automatic equivalent — they must be reimplemented as **extensions** (`@ExtendWith`/`@RegisterExtension`). Triage these; they're where the real effort is. 5. **Define the exit.** When the JUnit 4 test count hits zero, remove `junit-vintage-engine` and the `junit:junit` dependency. Until then, track the remaining count as a burn-down metric. ## Trade-offs of leaning on Vintage **Pros** - **No big-bang.** Feature work continues; risk is spread over time. - **Single test run.** CI reports old + new tests together; coverage never drops during migration. - **Low immediate cost.** Just a dependency, no code change to start. **Cons / sharp edges** - **It runs tests, not extensions.** Vintage executes JUnit 4 *test logic*, but custom runners and many Rules don't migrate themselves — those tests still need rework to get JUnit 5 features. - **Two mental models.** Contributors must know two APIs; juniors may copy the wrong style. Lint/templates help. - **Cognitive & build cost.** Two engines, two dependency sets; slightly more to discover/run, and a lingering JUnit 4 dependency (and its CVE/maintenance surface). - **Stagnation risk.** 'Vintage forever' is a smell — the un-migrated tests never gain Jupiter's capabilities. Make it time-boxed with an owner and a burn-down. ## Senior judgement calls - **Don't** machine-convert blindly without review — generated diffs can subtly change semantics (e.g. assertion message position, exception-matching breadth). - **Do** prioritize migrating the *flaky/most-edited* tests, where Jupiter's features (better parameterization, conditional execution) pay off fastest. - **Measure** progress (JUnit-4-class count → 0) so the migration actually finishes instead of becoming permanent dual-running. ## Mental model Vintage is a temporary *adapter* that lets the old plug into the new socket. It's scaffolding for the move, not a destination — keep it only as long as the burn-down chart hasn't reached zero.
- Vintage runs your JUnit 4 tests, so are they now 'JUnit 5 tests'?No — they still use the JUnit 4 API and gain none of Jupiter's features (extensions, parameter injection, nested tests). Vintage only executes them on the new Platform; they remain JUnit 4 tests until rewritten.
- What's a sign your Vintage usage has become a problem rather than a tool?No burn-down or owner — the JUnit 4 test count stops decreasing and dual-running becomes permanent, leaving a lingering JUnit 4 dependency and two diverging test styles with no plan to converge.
Vintage is a travel plug adapter: it lets your old appliance work in the new outlet while you replace appliances one at a time — you don't keep the adapter forever.
saying these in an interview costs you the question
- Claiming Vintage auto-converts JUnit 4 tests to Jupiter syntax (it runs them as-is)
- Assuming custom @RunWith runners and Rules migrate automatically
- Proposing a big-bang rewrite when incremental adoption is the supported path
- Treating permanent dual-running as the end state with no exit criterion