A team turns on JUnit 5 Jupiter's parallel test execution and their Jupiter tests speed up, but the legacy tests running through junit-vintage-engine still take exactly as long and run one after another. Explain why, and what options they actually have to shorten that part of the run.
answer
- parallelism is Jupiter's, not the platform's
- junit.jupiter.* params never reach Vintage
- Vintage runs JUnit 4 runners sequentially
- fork JVMs = engine-agnostic win
- JUnit 4 has no @ResourceLock equivalent
basics
~20 sParallel execution is a Jupiter-engine feature configured by junit.jupiter.execution.parallel.* parameters, not a platform feature. The vintage engine does not implement it and always runs JUnit 4 tests sequentially. Options: JUnit 4's own ParallelComputer or parallel Suite runners, process-level forking from the build, or migrating the classes to Jupiter.
solid answer
~60 sConcurrency in JUnit 5 lives **inside an engine**, not in the platform. Jupiter implements a hierarchical execution model with `junit.jupiter.execution.parallel.enabled` and the `parallel.mode.default` / `mode.classes.default` parameters, plus `@Execution`, `@ResourceLock` and `@Isolated` to control it. The vintage engine implements none of that: it delegates to JUnit 4 `Runner`s, which own their own execution loop, so it executes descriptors sequentially and ignores every Jupiter configuration parameter. The realistic options, roughly in order of cost: 1. **Fork at the process level** — the build tool starts several test JVMs; each runs a slice of the suite sequentially. This works for any engine and is usually the fastest thing to reach for, at the cost of JVM startup and no shared caches. 2. **JUnit 4's own parallelism** — `ParallelComputer`, or a `Suite`-style runner with a thread-pool scheduler (`RunnerScheduler`). It is coarse and applies per suite class, and legacy tests are often not thread-safe anyway. 3. **Migrate the hot classes to Jupiter**, which is the only way to get Jupiter's fine-grained parallelism with resource locks.
go deeper
It is enough to say parallelism belongs to the Jupiter engine and vintage tests run sequentially.
Name the Jupiter configuration parameters, explain that Vintage delegates to JUnit 4 runners and ignores them, and mention process-level forking as the generic alternative.
Reason about the goal: measure the slow classes, weigh fork-level parallelism plus isolation work against migrating the hot classes, and call out the thread-safety debt in legacy suites.
Frame it as a build-time budget decision — where concurrency is bought (process vs thread), what isolation each level demands, and how migration effort is prioritised by measured wall-clock contribution rather than by tidiness.
## Where concurrency lives A common misconception is that "JUnit 5 supports parallel tests". More precisely: **Jupiter** supports parallel tests. The JUnit Platform defines discovery, filtering, execution requests and reporting; it does not define a threading model. Each `TestEngine` decides how to execute the descriptors it produced. Jupiter chose to implement a hierarchical, work-stealing execution with configurable modes; the vintage engine chose the simplest possible thing — hand the class to its JUnit 4 `Runner` and let that runner run, on the calling thread. So the entire Jupiter configuration surface is invisible to Vintage: ``` junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.mode.classes.default=concurrent ``` These are *Jupiter* configuration parameters (the `junit.jupiter.` prefix is the giveaway). Nothing in Vintage reads them, and neither `@Execution(CONCURRENT)` nor `@ResourceLock` nor `@Isolated` has any meaning for a JUnit 4 class — they are Jupiter annotations processed by the Jupiter engine. The visible symptom is exactly the one described: after enabling parallelism, the Jupiter half of the suite gets faster and the vintage half does not move at all, and no configuration change on the JUnit 5 side ever helps. ## Option 1: process-level forking (build-managed) The engine-agnostic answer is to run several test JVMs, each executing a subset of classes sequentially. Both major build tools can do this, and because the parallelism is between processes it works no matter which engine owns a class. The trade-offs are real: - JVM startup and warm-up are paid per fork; for short suites the overhead can eat the gain. - Shared external resources (a database, a port, a temp directory, a static counter used for fixtures) now have several writers, so tests that were merely *sequentially* unsafe become flaky. Per-fork isolation — a database schema or container per fork, ephemeral ports — is usually required. - Reporting is aggregated by the build tool, so a single flaky test is harder to attribute. ## Option 2: JUnit 4's own parallelism JUnit 4 has its own, much coarser mechanisms: - `ParallelComputer.classes()` / `methods()` used through `JUnitCore` — awkward under the platform, since Vintage drives runners itself. - A suite runner with a custom `RunnerScheduler` that submits children to a thread pool. This is how most "parallel JUnit 4" setups worked, and it parallelises the classes named by that suite only. The deeper problem is rarely mechanical. JUnit 4 suites that grew over years typically rely on static fixtures, `@ClassRule`-managed singletons, shared temp files, system properties and time-of-day — exactly the things that break under concurrency. There is no `@ResourceLock` equivalent in JUnit 4, so the coordination has to be written by hand with locks or by partitioning the suite. ## Option 3: migrate the hot classes If the legacy portion is the critical path of CI, the highest-value move is usually not to parallelise JUnit 4 but to migrate the *slowest* classes to Jupiter, where you get per-class or per-method concurrency plus `@ResourceLock` to declare shared resources and `@Isolated` to fence the genuinely un-parallelisable ones. Migration is then driven by wall-clock data rather than by tidiness. ## Other Vintage limitations in the same family Interviewers often bundle several "why doesn't X work in Vintage" facts: - **No Jupiter extension model.** `@ExtendWith` and every Jupiter callback are ignored for JUnit 4 classes; conversely, `@Rule` and `@RunWith` do nothing on Jupiter classes. - **Jupiter-only configuration is ignored** — display-name generators, test-instance lifecycle, method orderers, timeout defaults (`junit.jupiter.execution.timeout.*`). - **A JUnit 4 jar is mandatory** at test runtime; Vintage is an adapter, not a reimplementation, and 4.12 is the documented minimum. - **Ordering** inside a JUnit 4 class is whatever the JUnit 4 runner does (`@FixMethodOrder`), not Jupiter's `@TestMethodOrder`. ## How to answer The strong answer names the boundary first ("parallelism is Jupiter's, not the platform's"), then reasons about the *actual* goal — shorter CI — rather than about the annotation. Measure which classes dominate wall-clock time; fork at the process level for a quick, engine-agnostic win with isolation work; migrate the expensive legacy classes to Jupiter where fine-grained concurrency and resource locks are available; and treat "make the whole legacy suite parallel in place" as the most expensive and least reliable path.
- Would adding @Execution(ExecutionMode.CONCURRENT) to a JUnit 4 test class change anything?No. @Execution is a Jupiter annotation read by the Jupiter engine when it builds its execution tree. A class run by the vintage engine is handed to a JUnit 4 runner that never inspects it, so the annotation is inert — it does not even produce a warning.
- What breaks first when you fork the test run into several JVMs?Anything shared outside the JVM: a single database schema, fixed ports, files in a shared temp directory, and test containers reused across the suite. Each fork needs its own isolated instance — per-fork schema or container, ephemeral ports, per-fork temp roots — otherwise you trade run time for flakiness that is much more expensive to debug.
- How do you decide which legacy classes to migrate first if the goal is a faster build?By wall-clock contribution, not by age or ugliness. Take the per-class durations from the test reports, sort descending, and migrate the head of that list to Jupiter so those classes can run concurrently with resource locks. That converts migration effort directly into CI minutes saved and gives a measurable stopping point.
saying these in an interview costs you the question
- Claiming parallel execution is a JUnit Platform feature so all engines inherit it
- Expecting @ResourceLock or @Isolated to protect JUnit 4 tests running under Vintage
- Saying the vintage engine can be configured for parallelism via junit.jupiter.execution.parallel properties
- Assuming legacy JUnit 4 tests are thread-safe and can simply be run concurrently
- Believing you must migrate everything to Jupiter before any part of the run can be parallel