You inherit a multi-module Gradle project with thousands of JUnit 4 tests. How would you introduce the Vintage engine to enable a safe, incremental migration to JUnit 5 across all modules, and how do you keep it from becoming permanent?
answer
- Convention plugin wires platform + both engines
- fail-on-no-discovered-tests guards silent zero
- New tests Jupiter-only, burn down JUnit 4 count
- Sunset per module: drop deps + ArchUnit ban
- BOM owns versions; bridge with a demolition date
basics
~20 sAdd a convention plugin that wires useJUnitPlatform() plus both engines everywhere, keeping all JUnit 4 tests green. Write new tests in Jupiter, migrate old ones module by module, and track remaining Vintage usage so you can delete the engine when it hits zero.
solid answer
~50 sTreat it as a program, not a flag flip. **(1) Centralize the wiring** in a convention plugin (`buildSrc` or a `build-logic` included build) that applies `useJUnitPlatform()` and adds `junit-jupiter` + `junit-vintage-engine` (versions from the `junit-bom`) to every module — so no module drifts. **(2) Establish a baseline**: turn the whole suite green under Vintage with zero test changes, and enable fail-on-no-discovered-tests so a module that silently runs 0 tests is caught. **(3) Set policy**: new tests must be Jupiter; converts happen module-by-module, lowest-risk first. **(4) Make progress visible**: a CI metric counting `org.junit.Test` vs `org.junit.jupiter.api.Test` usages, trending down. **(5) Plan the sunset**: when a module's JUnit 4 count hits zero, the convention plugin (or a per-module override) drops `junit:junit` and the Vintage engine; an ArchUnit/lint rule then forbids re-introducing `org.junit.Test`. The goal is a bridge with an explicit demolition date, not a permanent dependency.
code
kotlin · 12 lines// build-logic convention plugin applied to every module
tasks.withType<Test>().configureEach {
useJUnitPlatform()
failOnNoDiscoveredTests = true // catch modules that silently run 0 tests
}
dependencies {
"testImplementation"(platform("org.junit:junit-bom:5.10.2"))
"testImplementation"("org.junit.jupiter:junit-jupiter")
"testImplementation"("junit:junit:4.13.2") // remove when module hits 0
"testRuntimeOnly"("org.junit.vintage:junit-vintage-engine") // remove when module hits 0
}go deeper
Understand that the migration keeps old tests running while new ones use JUnit 5; details are beyond junior scope.
Explain the per-module add-engine-then-migrate loop and the need for version alignment.
Design the convention-plugin wiring, the silent-zero guard, and a per-module burn-down with Jupiter equivalents for legacy runners.
Own the program: centralized wiring, CI debt metrics, regression bans (ArchUnit), version governance via BOM, and an explicit demolition date for Vintage.
## Frame: Vintage is a bridge with a demolition date The Vintage engine exists so a JUnit 4 codebase can run on the JUnit Platform **today** while you migrate to Jupiter **incrementally**. The failure mode is leaving Vintage in forever — so design the rollout to make removal the natural endpoint. ## 1. Centralize the wiring (no per-module drift) In a large repo, copy-pasting test config into every `build.gradle.kts` guarantees drift. Put it in a **convention plugin**: ```kotlin // build-logic/src/main/kotlin/katajob.test-conventions.gradle.kts plugins { `java` } dependencies { val junitBom = platform("org.junit:junit-bom:5.10.2") "testImplementation"(junitBom) "testImplementation"("org.junit.jupiter:junit-jupiter") // Legacy bridge — present only while a module still has JUnit 4 tests "testImplementation"("junit:junit:4.13.2") "testRuntimeOnly"("org.junit.vintage:junit-vintage-engine") } tasks.withType<Test>().configureEach { useJUnitPlatform() // Make a module that discovers 0 tests fail loudly instead of going green failOnNoDiscoveredTests = true } ``` Apply `id("katajob.test-conventions")` in each module. Now wiring is one place; sunsetting is one edit. ## 2. Baseline green, then guard the silent-zero failure mode The most common migration accident is a module that runs **0 tests** but stays green (engine missing / filter wrong). Enabling fail-on-no-discovered-tests converts that into a build failure, protecting the baseline. Snapshot test counts per module before and after the switch and diff them. ## 3. Policy: new code is Jupiter Write new tests only in `org.junit.jupiter.api`. This stops the JUnit 4 debt from growing while you pay it down. Pair runner migrations with their Jupiter equivalents: `@RunWith(SpringRunner.class)` → `@SpringBootTest`/`SpringExtension`; `@RunWith(MockitoJUnitRunner.class)` → `@ExtendWith(MockitoExtension.class)`; `Parameterized` → `@ParameterizedTest`. ## 4. Make the debt visible Add a CI step (or a custom Gradle task) that counts imports of `org.junit.Test` per module and publishes the trend. A burn-down chart turns 'migrate someday' into a tracked, shrinking number. Optionally fail the build if a module's JUnit 4 count *increases*. ## 5. Sunset per module, then forbid regression When a module's JUnit 4 count reaches zero: - Remove `junit:junit` and `junit-vintage-engine` (a module-level override of the convention, or split the convention into 'with-vintage' vs 'jupiter-only'). - Add an **ArchUnit rule** or ktlint/checkstyle ban on `org.junit.Test` so nobody reintroduces JUnit 4 in that module. When *every* module is zero, delete the Vintage wiring from the convention plugin entirely. That is the demolition date. ## 6. Risks to manage - **Version skew**: only the `junit-bom` should set Platform/Jupiter/Vintage versions; never pin them individually or you risk `LinkageError`. - **Flaky parallelism**: switching engines can change execution order; watch for order-dependent tests surfaced by the move. - **Reporting/coverage**: confirm JaCoCo and the test report aggregate both engines (they do, since it's one task) so dashboards don't regress. The whole design keeps Vintage **scoped, measured, and removable** — a temporary on-ramp to Jupiter, not a permanent fixture.
- How do you stop the JUnit 4 debt from growing during a multi-year migration?Policy that new tests must be Jupiter, enforced by a lint/ArchUnit rule plus a CI metric that fails the build if a module's org.junit.Test count increases. Migration only ever trends down.
- What makes Vintage 'removable' rather than permanent in your design?Centralized wiring in one convention plugin and a per-module burn-down to zero, after which you drop junit:junit + the Vintage engine and add an ArchUnit ban on org.junit.Test so it can't return.
- Why insist all engine versions come from the junit-bom?The Platform launcher, Jupiter, and Vintage share an internal version contract; pinning them independently risks NoSuchMethodError/LinkageError at discovery. The BOM keeps them aligned.
saying these in an interview costs you the question
- Treating Vintage adoption as a one-line flag rather than a governed program.
- Leaving fail-on-no-discovered-tests off, so a misconfigured module silently runs zero tests.
- Pinning Jupiter/Vintage/Platform versions individually instead of via the junit-bom.
- Having no sunset plan, so the Vintage engine becomes a permanent dependency.