skip to content

You inherit a codebase with several thousand JUnit 4 tests and are asked to move to JUnit 5 without ever having a period where tests are not running. How do you sequence that migration and keep it from stalling half-done?

level: seniorimportance: should knowfreq 34%

answer

  1. Both engines run side by side — migrate per class, never a big bang
  2. Port shared rules/runners to Extensions FIRST
  3. Automate renames, review expected=/assert order/leftover @Rule/static @BeforeAll
  4. Verify by test COUNT before vs after, not by green
  5. Ratchet: ban org.junit imports in migrated modules; done = dependency deleted

basics

~20 s

Run both engines side by side: keep JUnit 4 tests executing while newly converted classes run on Jupiter. Migrate module by module with automated conversion plus review, forbid new JUnit 4 tests with a static check, track a burn-down of remaining old imports, then delete JUnit 4 when it hits zero.

solid answer

~60 s

**Coexistence first.** JUnit 5 is designed so both APIs can execute in the same run — the JUnit 4 tests keep running unchanged while converted classes run on Jupiter. Nothing has to move in a big bang, and there is never a gap in coverage. **Then a repeatable loop, one module or package at a time:** 1. Freeze the target's test count and record it. 2. Run automated conversion (IDE migration action or a recipe-based rewrite tool) for the mechanical renames. 3. Review the diff for the four things automation gets wrong: `@Test(expected=)`/`timeout=` conversions, assertion argument order, leftover `@Rule`/`@RunWith`/`@Ignore`, and `static` on `@BeforeAll`. 4. Compare the test count again — any drop is a lost test, not a win. 5. Port shared rules to Extensions **once**, centrally, before converting the classes that use them. **Then keep it from stalling.** Add a static check banning `org.junit.*` imports in already-migrated modules so the ratchet only turns one way, publish the remaining-JUnit-4 count as a visible number, and make the last step — deleting the JUnit 4 dependency — an explicit ticket. Half-migrated codebases stall because nobody owns the last 5%.

go deeper

for a junior

Say that both styles can run at once so the move can be gradual, class by class.

for a middle

Describe the per-module loop and the specific things automated conversion gets wrong.

for a senior

Lead with verification (counts, mutation) and with sequencing shared infrastructure first; name the ratchet that stops regressions.

for a principal

Treat it as a programme: ownership-aligned units, a published burn-down, an explicit owner for the awkward tail, and 'done' defined as removing the old dependency.

## Why coexistence is the whole strategy JUnit 5's architecture separates the platform (discovery and execution) from the programming model, and more than one engine can participate in a single run. Jupiter executes the new-style tests; a JUnit 4 engine executes the untouched ones. The practical effect is that migration is a **per-class** operation with no coordination cost: convert one class, everything else keeps running exactly as before. Any plan that involves a freeze, a branch that converts everything, or a period of reduced coverage is strictly worse and much riskier. Both API jars stay on the test classpath during the transition. That is the price of coexistence and the thing you are working to remove. ## Sequencing **1. Inventory.** Count tests, and count the JUnit 4 constructs that are *not* one-to-one: custom runners (`@RunWith`), custom rules, `@Category` hierarchies, Hamcrest usage. These decide the real cost. A corpus of plain `@Test`/`@Before` classes converts almost mechanically; one built on ten home-grown runners does not. **2. Do the shared infrastructure first.** Every custom rule or runner used by many classes should be ported to an `Extension` *before* the classes that use it. Otherwise each class migration is blocked on the same decision, and you end up with several inconsistent ports. Common ports: `ExternalResource` → `BeforeEachCallback`/`AfterEachCallback` extension; `TestWatcher` rule → `TestWatcher` extension; `@RunWith(MockitoJUnitRunner.class)` → `@ExtendWith(MockitoExtension.class)`; `@RunWith(SpringRunner.class)` → `@ExtendWith(SpringExtension.class)`. Note that extensions compose where runners did not, so several formerly-conflicting runners often collapse into a clean stack. **3. Pick migration units that match ownership.** A module, package or team's directory. Migrating by ownership means the people reviewing the diff know what the tests are supposed to prove — which matters because the risky part of a migration is semantic, not syntactic. **4. Automate the mechanical part, review the rest.** IDE migration actions and recipe-based rewriting tools handle imports, `@Before` → `@BeforeEach`, `@Ignore` → `@Disabled`, and often `expected=` → `assertThrows`. What still needs eyes: whether the `assertThrows` lambda wraps only the call under test, whether assertion messages moved to the last argument correctly, whether any `@Rule`/`@RunWith` survived (Jupiter ignores them silently), and whether `@BeforeAll` is static or the class deliberately became `@TestInstance(PER_CLASS)`. **5. Verify by counting, not by colour.** Record executed, skipped and failed counts before and after each unit. A green run proves nothing after a migration, because every one of the silent traps removes tests rather than breaking them. Equal counts plus a green run is real evidence. A deliberate mutation of production code that must turn the migrated tests red is even better for a critical module. ## Keeping the ratchet from slipping A migration that stalls at 80% is the common failure, and it is expensive: two APIs, two idioms, doubled onboarding cost, and a dependency you cannot drop. - **Ban regressions.** In every migrated module, a static check (import ban via Checkstyle/forbidden-apis, or an ArchUnit rule) that forbids `org.junit.Test`, `org.junit.Rule`, `org.junit.runner.RunWith`, `org.junit.Ignore` and `org.junit.Assert`. New JUnit 4 tests then fail the build instead of quietly accumulating. - **Publish the number.** "1,240 JUnit 4 tests remaining" as a tracked metric makes progress and stalling both visible. - **Timebox and own the tail.** The last classes are always the awkward ones — exotic runners, parameterised suites, rules with statement rewriting. Assign them explicitly rather than hoping they get swept up. - **Define done as dependency removal.** The migration is complete when the JUnit 4 API artifact and the engine that runs it are gone from the test classpath. Until then the old style can creep back. ## What I would not do I would not rewrite tests "while I am in there". Migration diffs must be reviewable as mechanical; mixing behavioural improvements into them destroys the one property that makes a large migration safe — that a reviewer can check the transformation rather than re-derive the intent. Improve the tests in a separate change once they are on the new API. I would also not migrate tests that are about to be deleted. Inventory usually finds a tail of tests for dead code; deleting is cheaper than converting.

  • Why is a green build after migrating a module insufficient evidence?
    Because the characteristic failures remove tests rather than break them: a leftover org.junit.Test annotation, a private method, or an ignored @Rule all leave a passing run with less coverage. Comparing executed and skipped test counts before and after catches exactly that class of loss, and deliberately mutating production code to confirm the tests go red catches the rest.
  • What do you do about a home-grown JUnit 4 runner used by two hundred classes?
    Port it once to a Jupiter Extension before touching the classes, because extensions compose and can usually be split into focused callbacks. If the runner did several unrelated things, that split is an improvement in itself. Only after the extension exists and is proven on a handful of classes do you convert the remaining ones, so every diff is mechanical.

saying these in an interview costs you the question

  • Proposing a big-bang migration or a freeze on writing tests during it
  • Not knowing that JUnit 4 and JUnit 5 tests can execute in the same run
  • Migrating classes before porting the shared rules and runners they depend on
  • Judging a migrated module by a green run instead of comparing test counts
  • Leaving the migration permanently partial with no check preventing new JUnit 4 tests

context