How can you run JUnit 4 and JUnit 5 tests together in the same Gradle test task, and what makes that possible?
answer
- Platform registers many engines via ServiceLoader
- Jupiter claims org.junit.jupiter, Vintage claims org.junit
- Both on testRuntimeOnly, one task, one report
- junit-jupiter aggregate = API + engine
- Enables incremental migration
basics
~10 sCall useJUnitPlatform() and put both engines on testRuntimeOnly: junit-jupiter-engine for JUnit 5 and junit-vintage-engine for JUnit 4. The Platform discovers both and runs each test with its matching engine.
solid answer
~40 sThe JUnit Platform can register **multiple `TestEngine`s** at once. When both `junit-jupiter-engine` and `junit-vintage-engine` are on the test runtime classpath and the `Test` task uses `useJUnitPlatform()`, the Platform's `Launcher` discovers both via `ServiceLoader`. During discovery each engine claims the classes it understands: Vintage claims classes using `org.junit.Test`/`@RunWith`, Jupiter claims classes using `org.junit.jupiter.api.Test`. They run in one task, one report, one coverage pass. This is the mechanism that makes **incremental migration** possible — you add Vintage, keep all your JUnit 4 tests green, and write new tests in Jupiter, converting old ones file by file. Practically you need three deps: `junit:junit` (JUnit 4 API), `org.junit.jupiter:junit-jupiter` (Jupiter API + engine aggregate), and `org.junit.vintage:junit-vintage-engine` on `testRuntimeOnly`.
code
kotlin · 10 linesdependencies {
testImplementation(platform("org.junit:junit-bom:5.10.2"))
testImplementation("junit:junit:4.13.2")
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine")
}
tasks.named<Test>("test") {
useJUnitPlatform()
}go deeper
Know that both engines can coexist if both are on testRuntimeOnly and the task uses useJUnitPlatform().
Explain the ServiceLoader discovery and how each engine selects classes by annotation package.
Tie it to an incremental migration plan and version alignment via the junit-bom.
Set org-wide policy: Vintage as a temporary bridge, dashboards tracking remaining JUnit 4 tests, deadline to remove it.
## One platform, many engines The JUnit Platform's `Launcher` does not know about JUnit 4 or JUnit 5 directly. It knows about the **`TestEngine` SPI**. At startup it asks the JVM `ServiceLoader` for every registered `TestEngine` on the runtime classpath. Each engine has a unique id (`junit-jupiter`, `junit-vintage`) and a `discover(...)` method. When you run the test task, discovery walks the test classes and hands them to the engines. Each engine **selects** the classes it can run: - **Vintage** recognizes classes annotated with `org.junit.Test`, classes with `@RunWith`, JUnit 3 `TestCase` subclasses, etc. - **Jupiter** recognizes classes with `org.junit.jupiter.api.Test`, `@TestFactory`, `@Nested`, etc. Because the two annotation packages differ (`org.junit.Test` vs `org.junit.jupiter.api.Test`), there is no ambiguity about which engine owns a class. ## The Gradle wiring ```kotlin dependencies { // JUnit 4 API (compile) + Vintage engine (runtime) for legacy tests testImplementation("junit:junit:4.13.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.10.2") // JUnit 5: the aggregate brings the Jupiter API (compile) and engine (runtime) testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") } tasks.named<Test>("test") { useJUnitPlatform() } ``` Note `org.junit.jupiter:junit-jupiter` is an **aggregate** that pulls `junit-jupiter-api` (compile) and `junit-jupiter-engine` (runtime). Vintage is added separately because it is purely a runtime engine. ## Why this enables migration A large legacy suite cannot be rewritten overnight. The mixed-engine setup lets you: 1. Flip the task to `useJUnitPlatform()` and add Vintage — every existing JUnit 4 test still runs, unchanged. 2. Start authoring **new** tests with Jupiter. 3. Migrate old tests opportunistically; when the last JUnit 4 test is gone, drop `junit:junit` and `junit-vintage-engine`. ## Version alignment matters All Platform/Jupiter/Vintage artifacts must use **compatible versions** — the engines and the platform-launcher share an internal version. Mismatches cause `NoSuchMethodError`/`LinkageError` at discovery. Use the `junit-bom` (`testImplementation(platform("org.junit:junit-bom:5.10.2"))`) and declare the engines without versions so they align automatically. (Choosing the BOM version itself is the sibling 'Version Alignment via BOM' topic.) ## What you do NOT get Vintage runs JUnit 4 tests *as JUnit 4 tests*. It does not magically give them Jupiter features (parameterized `@ParameterizedTest`, extensions, `@Nested`). Those require actually rewriting the test in the Jupiter model.
- How does each engine decide which test classes it owns?During discovery each engine inspects the candidate classes and selects those whose annotations/structure it recognizes — Vintage on org.junit.Test/@RunWith/TestCase, Jupiter on org.junit.jupiter.api.Test. The differing annotation packages keep ownership unambiguous.
- After migration is complete, what should you remove?Drop junit:junit (JUnit 4 API) and org.junit.vintage:junit-vintage-engine; keep only the Jupiter aggregate and useJUnitPlatform().
The Platform is a power strip and each engine is a device plugged in: JUnit 4 tests plug into the Vintage socket, JUnit 5 tests into the Jupiter socket — both draw from the same strip in one run.
saying these in an interview costs you the question
- Claiming Vintage upgrades JUnit 4 tests to Jupiter features — it only executes them as JUnit 4.
- Thinking you must split JUnit 4 and JUnit 5 into separate test tasks — one task runs both.
- Mismatching Jupiter and Vintage versions, causing LinkageError at discovery.