A legacy module uses TestNG and you're introducing JUnit 5 for new tests. How do you let both run under Gradle, and what are the constraints?
answer
- one Test task = one framework
- second source set + own Test task
- useTestNG() vs useJUnitPlatform()
- check.dependsOn(junitTest)
- no Platform engine for TestNG (unlike Vintage)
basics
~20 sA single Test task supports only one framework, so create a second source set with its own Test task: keep useTestNG() on the original, add useJUnitPlatform() on the new one. Wire the new task into check.
solid answer
~40 sGradle's `Test` task is backed by exactly one framework, so you cannot have `useTestNG()` and `useJUnitPlatform()` on the same task. To run both, separate them by **source set + Test task**. Keep the existing `test` task on `useTestNG()` for legacy tests, then add a new source set (e.g. `junitTest`) with its own dependencies and a registered `Test` task calling `useJUnitPlatform()`. Make `check` depend on the new task so both run in the gate. Wire the new source set's classpath via `configurations` extending from the main/test classpaths. Constraints: each framework needs its own dependencies; reports land in separate directories; and you must avoid mixing annotations in one compiled class. This pattern is also the migration runway — move classes from the TestNG source set to the JUnit one incrementally, deleting the TestNG task once empty.
code
kotlin · 7 linesval junitTest by tasks.registering(Test::class) {
testClassesDirs = sourceSets["junitTest"].output.classesDirs
classpath = sourceSets["junitTest"].runtimeClasspath
useJUnitPlatform()
}
tasks.named<Test>("test") { useTestNG() }
tasks.named("check") { dependsOn(junitTest) }go deeper
Recall that one Test task = one framework, so you need a second task for the other.
Sketch the second-source-set + registered Test task with useJUnitPlatform, wired into check.
Detail classpath/dependency wiring, separate reports, the no-Vintage-for-TestNG distinction, and the migration runway.
Decide coexistence-as-transition vs long-lived, weighing doubled maintenance surface, and set the org migration policy.
## The hard constraint A Gradle `Test` task resolves to a single `TestFramework`. Calling `useJUnitPlatform()` after `useTestNG()` simply replaces it — you don't get both. So coexistence is a **task-multiplication** problem, not a single-task config problem. ## Pattern: a second source set with its own Test task ```kotlin sourceSets { create("junitTest") { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output } } val junitTestImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) } dependencies { // legacy testImplementation("org.testng:testng:7.9.0") // new "junitTestImplementation"(platform("org.junit:junit-bom:5.11.0")) "junitTestImplementation"("org.junit.jupiter:junit-jupiter") } val junitTest by tasks.registering(Test::class) { description = "Runs JUnit 5 tests" group = "verification" testClassesDirs = sourceSets["junitTest"].output.classesDirs classpath = sourceSets["junitTest"].runtimeClasspath useJUnitPlatform() } tasks.named<Test>("test") { useTestNG() } // legacy stays on TestNG tasks.named("check") { dependsOn(junitTest) } ``` ## Why this works - **Isolation of frameworks:** each `Test` task is single-framework, so each source set gets the right discovery and runner. - **Independent dependency graphs:** TestNG and Jupiter never collide on the same compile classpath. - **Gate integration:** `check.dependsOn(junitTest)` ensures CI runs both. ## Constraints and gotchas 1. **No annotation mixing in one class** — a compiled class is discovered by one framework. Don't put `org.testng.annotations.Test` and `org.junit.jupiter.api.Test` in the same class. 2. **Separate reports** — each Test task writes to its own `build/reports/tests/<taskName>` and `build/test-results/<taskName>`; aggregate in CI if you need one view. 3. **Classpath wiring** — the new source set must extend the main output and (often) the test configuration so shared helpers resolve. 4. **Vintage alternative** — if you only need to keep *JUnit 4* tests alongside JUnit 5, the JUnit Platform Vintage engine runs both under one `useJUnitPlatform()` task; that does **not** apply to TestNG, which has no Platform engine, hence the separate-task approach. ## As a migration runway This dual-task setup is the cleanest path off TestNG: author all new tests in the JUnit source set, port legacy classes over time, and when the TestNG source set is empty, drop the dependency and the `useTestNG()` task. The build stays green throughout. ## Decision note For large orgs, decide whether long-lived coexistence is acceptable or whether it's a time-boxed migration. Long-lived dual frameworks double the maintenance surface (two runners, two report shapes, two skill sets), so most teams treat it as transitional.
- Why can't you just call both useTestNG() and useJUnitPlatform() on the test task?A Test task is backed by exactly one framework; the second call replaces the first. You need separate Test tasks (typically via separate source sets).
- If the legacy tests were JUnit 4 instead of TestNG, would you still need a second task?No — the JUnit Platform Vintage engine runs JUnit 4 and 5 together under a single useJUnitPlatform() task. TestNG has no Platform engine, so it must stay on its own task.
- How do you ensure both test suites run in CI?Make the check task depend on the new Test task (check.dependsOn(junitTest)), so the verification lifecycle executes both.
saying these in an interview costs you the question
- Claiming a single Test task can run TestNG and JUnit 5 together.
- Suggesting the Vintage engine bridges TestNG — it only bridges JUnit 4.
- Mixing TestNG and JUnit annotations in one class and expecting both to be discovered.