A module has some test classes written with JUnit 5 Jupiter's org.junit.jupiter.api.Test and others still using JUnit 4's org.junit.Test. How does a single JUnit Platform run handle both, and what happens if one class contains methods annotated with both?
answer
- every engine sees the same discovery request
- annotation package decides the owner
- [engine:junit-jupiter] vs [engine:junit-vintage] ids
- hybrid class = discovered twice
- migrate a class atomically
basics
~20 sBoth engines run in the same launcher session and discover independently: Jupiter claims classes with Jupiter test methods, Vintage claims JUnit 3/4 classes. Results merge into one report under different engine ids. Mixing both APIs inside one class makes both engines claim it, so it executes twice — migrate a class wholesale.
solid answer
~50 sThe platform launcher asks every registered engine to discover against the same discovery request, then executes the merged plan. Jupiter picks up classes whose methods carry `org.junit.jupiter.api.Test` (or `@TestFactory`, `@ParameterizedTest`, ...); Vintage picks up classes that JUnit 4's runner builder recognises — `org.junit.Test` methods, `@RunWith` classes, `junit.framework.TestCase` subclasses. Each class is normally claimed by exactly one engine, and the results are reported under distinct roots (`[engine:junit-jupiter]`, `[engine:junit-vintage]`), which is why IDEs show two trees. The trap is a *hybrid* class. If one class has both a `org.junit.Test` method and a `org.junit.jupiter.api.Test` method, both engines consider it their own: Vintage runs the class through a JUnit 4 runner (running only the JUnit 4 methods, with JUnit 4 lifecycle) and Jupiter runs it separately (running only the Jupiter methods). The class is instantiated twice, `@Before` and `@BeforeEach` fire in different runs, and counts look strange. Rule: migrate a class completely or not at all.
go deeper
Say that both kinds of tests run in the same build because there are two engines, and that you should not mix the two APIs in one class.
Describe discovery per engine, engine-prefixed unique ids, and exactly why a hybrid class executes twice with split lifecycles.
Add the operational angle: engine filtering to measure legacy, shared static state across engines in one JVM, and a build guard that forbids mixed imports in a class.
Treat mixed-engine coexistence as a transitional state with a measurable burn-down, and make the invariant (one class, one dialect) enforced automatically rather than by review.
## One run, several engines The JUnit Platform's `Launcher` receives a single `LauncherDiscoveryRequest` ("scan this classpath root / these packages / these classes") and passes it to **every** `TestEngine` it finds via `ServiceLoader`. Each engine answers with its own tree of `TestDescriptor`s rooted at its engine id. The launcher stitches those roots into one `TestPlan` and then executes them, forwarding events to listeners. That is the whole coexistence mechanism: engines never negotiate with each other, they each answer the same question independently. So in a module that has `junit-jupiter-engine` and `junit-vintage-engine` available: - Jupiter's discovery keeps classes that contain Jupiter test methods (`@Test` from `org.junit.jupiter.api`, `@ParameterizedTest`, `@RepeatedTest`, `@TestFactory`, `@TestTemplate`) or `@Nested` inner classes. - Vintage's discovery keeps classes for which JUnit 4's `AllDefaultPossibilitiesBuilder` can produce a non-null `Runner`: classes with `org.junit.Test` methods, classes with `@RunWith`, JUnit 4 `Suite`s, and `junit.framework.TestCase` subclasses. Because the annotation packages differ (`org.junit.Test` vs `org.junit.jupiter.api.Test`), a normal class satisfies exactly one of those predicates. ## What the report looks like Unique ids carry the engine as the first segment: - `[engine:junit-jupiter]/[class:com.acme.OrderTest]/[method:createsOrder()]` - `[engine:junit-vintage]/[runner:com.acme.LegacyOrderTest]/[test:createsOrder(com.acme.LegacyOrderTest)]` That is what lets you point a run at one engine only: `includeEngines("junit-vintage")` / `excludeEngines("junit-vintage")` in a discovery request, `--select-engine`/`--exclude-engine` on the ConsoleLauncher, or `@SelectEngines` on a `@Suite` class. A common use is a scheduled run of only the legacy engine to track how many JUnit 4 tests remain, or excluding Vintage temporarily to see whether a failure is engine-specific. ## The hybrid class Nothing stops a developer from adding a Jupiter test method to a class that already has JUnit 4 methods — both annotations are just annotations, and both jars are on the classpath. The result is not an error but something worse: duplication with split semantics. - Vintage builds a JUnit 4 runner for the class. `BlockJUnit4ClassRunner` collects only methods annotated with `org.junit.Test`, so the Jupiter methods are invisible to it; the class is instantiated per JUnit 4 method and `@Before`/`@After` fire. - Jupiter discovers the same class independently, collects only its Jupiter methods, instantiates the class again under Jupiter's lifecycle, and fires `@BeforeEach`/`@AfterEach`. JUnit 4 `@Before` and `@Rule` do nothing here. So the class appears twice in the report, shared static state may be initialised twice, and setup written in one dialect silently does not run for the tests written in the other. Some runner combinations are worse: a JUnit 4 class-level `@RunWith` runner (say a Spring one) will start its context for the JUnit 4 half only, while the Jupiter half runs with no context at all and fails confusingly. The rule follows: **migration is per class, atomically**. Convert every method, the lifecycle annotations, the assertions import (`org.junit.Assert` → `org.junit.jupiter.api.Assertions`) and the rules in one edit, or leave the class alone. A cheap guard is an ArchUnit or Checkstyle/forbidden-apis rule that fails the build when both `org.junit.Test` and `org.junit.jupiter.api.Test` are referenced by the same class. ## Practical consequences of a mixed suite - **Ordering and grouping are per engine.** Engines execute in an unspecified relative order; do not build cross-engine dependencies. - **Configuration is per engine.** Jupiter's configuration parameters (lifecycle defaults, display-name generators, parallelism) apply to Jupiter descriptors only; Vintage ignores them. - **Shared static state is the real hazard.** Two engines in the same JVM share statics — a singleton container or an in-memory database started by a JUnit 4 `@ClassRule` and reused by Jupiter tests will bite depending on execution order. - **Reporting is uniform**, which is the payoff: one XML/HTML report, one IDE tree, one pass/fail signal.
- How would you run only the remaining JUnit 4 tests to count how much legacy is left?Filter by engine id at the platform level: a discovery request with includeEngines("junit-vintage"), --select-engine junit-vintage on the ConsoleLauncher, or a platform @Suite class annotated @SelectEngines("junit-vintage"). No source changes are needed because the engine id is part of every unique id.
- In a mixed module, why can a JUnit 4 @ClassRule that starts a shared container cause flaky Jupiter tests?Both engines run in the same JVM and share static state, but engine execution order is not specified. If the Jupiter tests implicitly rely on the container the JUnit 4 @ClassRule started, they pass only when Vintage happens to run first. The fix is to own the lifecycle explicitly on the Jupiter side, for example with an extension or a Jupiter-managed singleton.
saying these in an interview costs you the question
- Claiming the platform picks one engine per module, so JUnit 4 and Jupiter tests cannot run in the same build
- Saying a class can freely mix org.junit.Test and org.junit.jupiter.api.Test methods and everything just works
- Believing @Before from JUnit 4 will run before a Jupiter @Test in the same class
- Assuming Jupiter configuration parameters (for example lifecycle or parallelism settings) also govern vintage tests
- Thinking the two engines coordinate discovery so a class is guaranteed to be claimed only once