skip to content

JUnit 4 to 5 Migration

The practical migration playbook: annotation renames, ExpectedException to assertThrows, rules to extensions. A very common 'have you actually done it' interview topic.

on this pageshow

questions

5

When moving a test class from JUnit 4 to JUnit 5, what are the JUnit 5 equivalents of @Before, @BeforeClass, @After, @AfterClass, @Ignore and @Category, and what changes besides the names?

level: juniorimportance: must knowfreq 58%

answer

  1. Before→BeforeEach, BeforeClass→BeforeAll, Ignore→Disabled, Category→Tag
  2. @Test has no attributes: expected→assertThrows, timeout→@Timeout
  3. Assertions message moves to LAST parameter
  4. Not public required, but must not be private
  5. @BeforeAll static unless @TestInstance(PER_CLASS)

basics

~10 s

@Before becomes @BeforeEach, @BeforeClass becomes @BeforeAll, @After becomes @AfterEach, @AfterClass becomes @AfterAll, @Ignore becomes @Disabled, @Category(X.class) becomes @Tag("x"). Packages change to org.junit.jupiter.api, assertions move to Assertions, and the message argument moves to last.

solid answer

~50 s

The name mapping: | JUnit 4 | JUnit 5 (Jupiter) | |---|---| | `@Before` / `@After` | `@BeforeEach` / `@AfterEach` | | `@BeforeClass` / `@AfterClass` | `@BeforeAll` / `@AfterAll` | | `@Ignore` | `@Disabled("reason")` | | `@Category(Slow.class)` | `@Tag("slow")` | | `org.junit.Test` | `org.junit.jupiter.api.Test` | But the imports are the smaller half: - **`@Test` has no attributes.** `expected = X.class` becomes `assertThrows(X.class, () -> …)`; `timeout = 500` becomes `assertTimeout(...)` or `@Timeout(500, MILLISECONDS)`. - **Assertions move** from `org.junit.Assert` to `org.junit.jupiter.api.Assertions`, and the optional message becomes the **last** parameter instead of the first. - **Assumptions** move from `org.junit.Assume` to `org.junit.jupiter.api.Assumptions`. - **Visibility**: test classes and methods no longer need to be `public`, but must not be `private`. - **`@BeforeAll`/`@AfterAll` must be `static`** unless the class is `@TestInstance(PER_CLASS)`. - `@RunWith` is gone — its replacement is `@ExtendWith`. - Categories were marker *classes*; tags are *strings*, so a marker interface hierarchy does not translate one-to-one.

code

java · 19 lines
java
// JUnit 4
public class OrderTest {
    @Before public void setUp() {}
    @BeforeClass public static void once() {}
    @Ignore("flaky")
    @Test(expected = IllegalStateException.class)
    public void rejects() { new Order().submit(); }
}

// JUnit 5 (Jupiter)
class OrderTest {
    @BeforeEach void setUp() {}
    @BeforeAll static void once() {}
    @Disabled("flaky")
    @Test
    void rejects() {
        assertThrows(IllegalStateException.class, () -> new Order().submit());
    }
}

go deeper

for a junior

Recite the annotation mapping accurately and know the package change to org.junit.jupiter.api.

for a middle

Add the behavioural deltas: @Test attributes gone, assertion message last, static @BeforeAll, non-private visibility.

for a senior

Emphasise the silent failures — ignored @Rule/@RunWith, private methods not discovered, the three-string assertEquals trap — and how you would catch them in review.

for a principal

Treat the mapping as mechanical work for tooling, and focus on the review gate and static checks that prevent a green-but-not-running corpus.

## The straightforward renames JUnit 4's lifecycle annotations were named for *when* they ran relative to the class; Jupiter's are named for *what* they run around, which reads better: - `@Before` → `@BeforeEach`, `@After` → `@AfterEach` — run around every test method. - `@BeforeClass` → `@BeforeAll`, `@AfterClass` → `@AfterAll` — run once for the class. - `@Ignore` → `@Disabled`. Jupiter takes an optional reason string and shows it in reports, which is worth filling in. - `@Category(SlowTests.class)` → `@Tag("slow")`. - `@Test` itself changes package: `org.junit.Test` → `org.junit.jupiter.api.Test`. All of these are imports plus a rename, and automated tooling handles them well. ## The changes that alter behaviour ### `@Test` lost its attributes JUnit 4's `@Test(expected = IllegalStateException.class)` and `@Test(timeout = 500)` do not exist in Jupiter. Exceptions become `assertThrows`, which returns the thrown exception so you can assert on its message or cause. Timeouts become `@Timeout(value = 500, unit = MILLISECONDS)` on the method/class, or `assertTimeout`/`assertTimeoutPreemptively` around a specific block. This is not cosmetic: `expected=` passed if *anything anywhere* in the method threw that type, including the setup, whereas `assertThrows` scopes the expectation to one statement. ### Assertion signatures flipped `org.junit.Assert.assertEquals(String message, Object expected, Object actual)` puts the message first. `org.junit.jupiter.api.Assertions.assertEquals(Object expected, Object actual, String message)` puts it last. Most mis-migrations fail to compile, but the three-string case (`assertEquals("msg", "a", "b")`) compiles under both with completely different meanings — a real, silent defect. Jupiter also adds a `Supplier<String>` overload so the message is only built on failure. Jupiter drops `assertThat` entirely; if the class used Hamcrest matchers, keep Hamcrest (or move to AssertJ) as a separate dependency — Jupiter deliberately does not ship a matcher library. ### Assumptions `org.junit.Assume.assumeTrue` → `org.junit.jupiter.api.Assumptions.assumeTrue`. Semantics are similar (a failed assumption aborts rather than fails the test), and Jupiter adds `assumingThat(condition, executable)` to run only part of a test conditionally. ### Visibility and `static` JUnit 4 required `public` test classes and `public void` test methods. Jupiter uses different reflection rules: classes and methods must simply not be `private`, so package-private is idiomatic. A method left `private` during migration is **not discovered** — silently no longer running. `@BeforeAll`/`@AfterAll` must be `static` because, by default, Jupiter creates a **new test instance per test method** (`Lifecycle.PER_METHOD`), so there is no single instance to hold them. Annotate the class `@TestInstance(TestInstance.Lifecycle.PER_CLASS)` to get one instance per class and then non-static `@BeforeAll` is allowed. That choice also means fields survive between test methods, which is exactly the shared-state behaviour JUnit 4 users often assumed and which per-method lifecycle prevents. ### `@RunWith` and rules Jupiter has no runners. `@RunWith(MockitoJUnitRunner.class)` becomes `@ExtendWith(MockitoExtension.class)`, `@RunWith(SpringRunner.class)` becomes `@ExtendWith(SpringExtension.class)` (usually implied by `@SpringBootTest`). Unlike runners, extensions compose: several `@ExtendWith` on one class is fine, which was the single biggest limitation of the runner model. Any leftover `@RunWith` or `@Rule` in a Jupiter class is simply ignored — no error, no warning that stops the build. ### Categories versus tags `@Category` took marker *classes*, which supported inheritance: a category interface extending another matched both. `@Tag` takes *strings* with no hierarchy. Migrating a category hierarchy means flattening it — a test that was in a sub-category may need two tags. Tag strings must not be blank or contain whitespace, commas, or the characters `(`, `)`, `&`, `|`, `!`, since those are tag-expression operators. ## Practical advice Run an automated migration (IDE action or a recipe-based tool) for the mechanical renames, then review every diff for the four behavioural traps: `expected=`/`timeout=` conversions, assertion argument order, `static` on `@BeforeAll`, and any surviving `@RunWith`/`@Rule`/`@Ignore` from the JUnit 4 packages. Those four are where converted-and-green does not mean converted-and-correct.

  • Why must @BeforeAll be static by default, and how can it not be?
    Jupiter's default lifecycle is PER_METHOD: a fresh test instance is created for every test method, so there is no single instance on which a once-per-class method could run — hence static. Annotating the class @TestInstance(Lifecycle.PER_CLASS) creates one instance for the whole class, and then @BeforeAll and @AfterAll may be non-static. That also makes instance fields persist across test methods.
  • What is the risk of leaving a @Rule field in a class you converted to Jupiter?
    Jupiter does not know what a JUnit 4 @Rule is, so the field is ignored with no error. Whatever setup, teardown or assertion the rule provided silently stops happening, and the test can still pass for the wrong reason. Either replace it with an Extension (or a built-in like @TempDir), or use the migration-support extensions for the supported rule types.

saying these in an interview costs you the question

  • Believing Jupiter test classes and methods must still be public
  • Converting @Test(expected = X.class) to a try/catch with no fail() call, so a non-throwing case passes
  • Leaving the assertion message as the first argument after switching to Assertions
  • Keeping @RunWith or @Rule in a Jupiter class and assuming they still work
  • Thinking @Category marker classes map one-to-one onto @Tag strings, including inheritance

context

open as a page

JUnit 4 offered @Test(expected = SomeException.class) and the ExpectedException rule for exception tests. How do you express the same intent in JUnit 5, and why is the replacement considered stronger?

level: middleimportance: must knowfreq 52%

basics

~20 s

Use Assertions.assertThrows(SomeException.class, () -> code()), which returns the thrown exception so you can assert its message or cause. It scopes the expectation to one statement, unlike @Test(expected=), which passes if any line in the method throws that type.

open as a page

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?

level: middleimportance: should knowfreq 42%

basics

~20 s

Leftover 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.

open as a page

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%

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.

open as a page

What does JUnit 5's @EnableRuleMigrationSupport annotation do, which JUnit 4 rule types can it handle, and what should replace those rules once the migration is finished?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

From the junit-jupiter-migrationsupport artifact, it registers extensions that let a Jupiter test still honour JUnit 4 rules of three families: ExternalResource (including TemporaryFolder), Verifier (including ErrorCollector), and ExpectedException. Arbitrary TestRule or MethodRule implementations are not supported. It is a bridge, not a destination.

open as a page