skip to content

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?

level: principalimportance: should knowfreq 35%

answer

  1. Add junit-jupiter + junit-vintage-engine
  2. all NEW tests in Jupiter, migrate old opportunistically
  3. Vintage runs tests, not custom runners/Rules
  4. OpenRewrite for mechanical renames
  5. exit = drop Vintage + junit:junit when count hits 0

basics

~20 s

Add 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 s

The 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

for a junior

Know that adding the Vintage engine lets old JUnit 4 tests keep running alongside new JUnit 5 tests.

for a middle

Lay out the steps: add jupiter + vintage deps, write new tests in Jupiter, migrate old gradually, use useJUnitPlatform().

for a senior

Weigh the trade-offs — Vintage runs tests not extensions, two API styles, tooling like OpenRewrite, and define an exit criterion.

for a principal

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

context