skip to content

How can you run JUnit 4 and JUnit 5 tests together in the same Gradle test task, and what makes that possible?

level: middleimportance: must knowfreq 55%

answer

  1. Platform registers many engines via ServiceLoader
  2. Jupiter claims org.junit.jupiter, Vintage claims org.junit
  3. Both on testRuntimeOnly, one task, one report
  4. junit-jupiter aggregate = API + engine
  5. Enables incremental migration

basics

~10 s

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

The 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 lines
kotlin
dependencies {
    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

for a junior

Know that both engines can coexist if both are on testRuntimeOnly and the task uses useJUnitPlatform().

for a middle

Explain the ServiceLoader discovery and how each engine selects classes by annotation package.

for a senior

Tie it to an incremental migration plan and version alignment via the junit-bom.

for a principal

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.

context