A test class was converted from JUnit 4 to JUnit 5, it compiles and the run is green. Which silent changes could mean some of those tests are no longer running or no longer asserting what you think?
answer
- Leftover org.junit.Test → method never discovered
- @Rule/@RunWith/@Ignore silently ignored by Jupiter
- private method = not discovered (not-public is fine)
- assertEquals message is LAST — all-String calls compile both ways
- Guards: compare test counts, ban org.junit imports, mutate to check redness
basics
~20 sLeftover org.junit annotations (@Test, @Ignore, @Rule, @RunWith) are ignored by Jupiter, so methods never run or setup disappears. Private methods are not discovered. Flipped assertEquals argument order can compare the wrong values. Disabled tests silently become enabled or vice versa.
solid answer
~60 sGreen after a migration is weak evidence. The traps that produce a passing-but-wrong suite: 1. **Mixed imports.** A method still annotated `org.junit.Test` in an otherwise-Jupiter class is invisible to the Jupiter engine — the method simply never runs, and the class still reports success from the methods that did. 2. **Ignored JUnit 4 constructs.** `@Rule`, `@ClassRule`, `@RunWith` in a Jupiter class are silently ignored; the setup, cleanup or assertions they carried are gone. 3. **`@Ignore` left in place.** Jupiter does not honour it, so a knowingly-broken test either starts running (and may be flaky) or, if the method kept the JUnit 4 `@Test`, stops running entirely. 4. **Visibility.** A method left `private` is not discovered. Not public is fine; private is invisible. 5. **Assertion argument order.** Jupiter's message is the last parameter. `assertEquals("expected greeting", greeting)` and other all-String calls compile under both APIs with different meanings. 6. **Lifecycle semantics.** `@TestInstance(PER_CLASS)` (or its absence) changes whether fields survive between methods. The cheap guard: compare the executed-test **count** before and after, and ban `org.junit.*` imports in migrated modules with a static check.
go deeper
Name the leftover-annotation trap and the private-method trap.
Cover the full list including assertion argument order and the ignored @Rule, and know that Jupiter fails silently on unrecognised annotations.
Lead with detection: test-count comparison, import bans, deliberate mutation, and reviewing diffs where automation is weak.
Make it a policy question — the migration is not done when it compiles but when a check makes regression impossible, and that check must be enforced continuously, not once.
## Why green means little here A migration changes which annotations the engine recognises. Anything it does not recognise it ignores — quietly. So the failure mode is not a red build; it is a smaller, weaker suite that still says "passed". Every trap below has the same shape: *something stopped happening and nothing complained*. ## The traps ### 1. Leftover `org.junit.Test` Jupiter discovers `org.junit.jupiter.api.Test`. A method still carrying the JUnit 4 annotation is not a test to Jupiter. If the Vintage engine is also present, the *class* may be picked up by Vintage — but a class with mixed annotations is discovered by both engines and each runs only its own methods, so counts get confusing rather than wrong. Without Vintage the method vanishes. Because both annotations are named `Test`, a stale import is invisible at a glance. ### 2. `@Rule`, `@ClassRule`, `@RunWith` still present Jupiter knows nothing about these. Ignored, no warning. If a rule was creating a temporary folder, resetting a system property, starting a server, or collecting errors, that behaviour is gone and the test may still pass because its assertions were weak. This is the highest-impact silent trap. ### 3. `@Ignore` versus `@Disabled` `org.junit.Ignore` means nothing to Jupiter. Two outcomes: if `@Test` was migrated but `@Ignore` was not, a deliberately-disabled test starts running — sometimes fine, sometimes a new flake source. If neither was migrated, the method silently disappears. Either way the record of *why* it was disabled is lost, which is why `@Disabled("reason")` should always carry a reason. ### 4. Method visibility JUnit 4 required `public`; Jupiter requires "not private". A search-and-replace that turned `public void` into `private void` (or a reformat that did), or a method never made non-private, means the method is not discovered. Jupiter logs a warning for annotated-but-private methods, but build logs are rarely read. ### 5. Assertion argument order `Assert.assertEquals(String message, Object expected, Object actual)` vs `Assertions.assertEquals(Object expected, Object actual, String message)`. Most mismatches fail to compile — but when all arguments are Strings, both compile: ```java // JUnit 4 meaning: message "greeting", compare "hello" with actual assertEquals("greeting", "hello", actual); // Jupiter meaning: compare "greeting" with "hello", message = actual ``` That one always fails loudly if the values differ, but the variant `assertEquals("msg", value)` (two args) silently becomes a comparison of the message against the value. Review every two- and three-argument assertion where a message was involved. ### 6. Static and lifecycle `@BeforeAll`/`@AfterAll` must be `static` under the default per-method lifecycle; a non-static one raises an error, so that is loud. The quiet version is the opposite: adding `@TestInstance(PER_CLASS)` to make a non-static `@BeforeAll` legal also makes instance fields persist across test methods, so tests can now leak state into each other and pass in one order and fail in another. ### 7. Assumptions and Hamcrest `org.junit.Assume` calls left in place still compile if JUnit 4 is on the classpath, and they throw `AssumptionViolatedException` — which Jupiter does not recognise, so the test **fails** rather than aborting. That one is loud. Conversely, `Assert.assertThat` was removed from Jupiter; leaving it means still depending on JUnit 4 for assertions, which keeps the migration incomplete. ## How to detect all of this - **Count tests before and after.** The executed-test count from the JUnit 4 run and the Jupiter run should match, or every difference should be explained. This single check catches traps 1, 3 and 4. - **Ban the old package.** A static check (import ban, ArchUnit rule, or forbidden-apis) forbidding `org.junit.Test`, `org.junit.Rule`, `org.junit.Ignore`, `org.junit.Assert`, `org.junit.runner.RunWith` in migrated modules turns every leftover into a build failure instead of a silent skip. - **Mutate one test.** Break the production code deliberately and confirm the migrated tests go red. A suite that stays green under an obvious break is not testing anything. - **Read the diff for assertions.** Automated conversion is trustworthy for imports and reliably unreliable for argument order and lambda scope.
- What is the cheapest way to prove a migrated module did not lose tests?Compare the number of executed tests before and after the migration and account for every difference. Skipped and disabled counts matter too, since a test that quietly became disabled looks the same as one that disappeared. Follow it with an import ban on the old org.junit annotations so leftovers fail the build instead of vanishing.
- Why can adding @TestInstance(PER_CLASS) introduce order-dependent failures?With the default per-method lifecycle Jupiter creates a fresh instance for every test method, so instance fields start clean each time. PER_CLASS reuses one instance for the whole class, so mutations to fields leak between methods. Tests then depend on execution order and can pass locally while failing under a different order or in parallel.
saying these in an interview costs you the question
- Treating a green run after migration as proof the migration was correct
- Assuming Jupiter warns or fails when it meets JUnit 4 annotations
- Believing a private @Test method still runs because it is annotated
- Thinking Jupiter treats org.junit's AssumptionViolatedException as an abort
- Relying entirely on automated conversion without reviewing assertion arguments and lambda scope