skip to content

Test Runners & Suites

JUnit 4's runner and rule model set against JUnit 5's launcher and @Suite, and how Gradle and Surefire discover and run them. Interviewers ask because most real codebases still have both generations in the same build.

on this pageshow

explore

questions

20

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

In JUnit 4, what is a `@Rule`, and what can it do that plain `@Before` and `@After` methods cannot?

level: juniorimportance: must knowfreq 52%

basics

~20 s

A @Rule is a public, non-static field holding an object that wraps each test method. Because it wraps the call, it can run code before and after the test and also catch failures, retry, time out or skip it. @Before/@After only run beside the test.

open as a page

What does JUnit 4's `@RunWith` annotation do, and what executes a test class that carries no `@RunWith` at all?

level: juniorimportance: must knowfreq 46%

basics

~20 s

@RunWith(X.class) names the Runner class that takes over running that test class. Without it, JUnit falls back to its default runner — org.junit.runners.JUnit4, a subclass of BlockJUnit4ClassRunner — which finds the @Test methods, creates a new instance per method and applies @Before/@After and rules.

open as a page

The JUnit Platform can execute legacy JUnit 4 tests through an engine named junit-vintage-engine. Explain what that engine actually is, what kinds of test classes it accepts, and what it does NOT change about how those tests behave.

level: juniorimportance: must knowfreq 45%

basics

~20 s

junit-vintage-engine is a TestEngine implementation for the JUnit Platform. It discovers JUnit 3 (junit.framework.TestCase) and JUnit 4 classes, delegates execution to JUnit 4's own runners, and reports results back to the platform. The tests keep pure JUnit 4 semantics and gain no Jupiter features.

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

In JUnit 4, what is the difference between a field annotated `@Rule` and one annotated `@ClassRule` — how must each be declared, and what does each one's wrapped statement cover?

level: middleimportance: must knowfreq 44%

basics

~20 s

@Rule is a public non-static field; its statement covers the @Before methods, one test method and the @After methods, so it runs per test. @ClassRule is a public static TestRule field; its statement covers @BeforeClass, the whole class body and @AfterClass, so it runs once.

open as a page

How does JUnit 4's `Parameterized` runner work — where does the data come from, how does each data row reach the test instance, and how many instances of the test class are created?

level: middleimportance: must knowfreq 40%

basics

~20 s

Annotate the class @RunWith(Parameterized.class) and add a public static method annotated @Parameters returning Iterable<Object[]> or Object[][]. Each row is injected through a matching constructor, or into public fields annotated @Parameter(index). One instance is created per row per test method.

open as a page

JUnit 4 ships several ready-made rules — `TemporaryFolder`, `ExternalResource`, `Timeout`, `ExpectedException`, `TestName` and `ErrorCollector`. What does each one do, and which of them is now discouraged?

level: juniorimportance: should knowfreq 38%

basics

~20 s

TemporaryFolder makes a fresh directory per test and deletes it after. ExternalResource is a base class with before()/after() for any resource. Timeout fails an overlong test. TestName exposes the current method name. ErrorCollector gathers multiple failures. ExpectedException is discouraged — use Assert.assertThrows.

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

JUnit 4 defines two rule interfaces, `org.junit.rules.TestRule` and `org.junit.rules.MethodRule`. What does each one's `apply` method receive, and which should new code implement?

level: middleimportance: should knowfreq 30%

basics

~20 s

TestRule.apply takes (Statement base, Description description) — metadata only — and works for both @Rule and @ClassRule. MethodRule.apply takes (Statement base, FrameworkMethod method, Object target), giving you the test instance. TestRule is the recommended interface; use MethodRule only when you must touch the instance.

open as a page

What do JUnit 4's `Suite` and `Categories` runners do, and how do you tag a test class or method so a category filter selects it?

level: middleimportance: should knowfreq 26%

basics

~10 s

Suite aggregates other test classes: annotate an empty class @RunWith(Suite.class) and @Suite.SuiteClasses({A.class, B.class}). Categories extends Suite and filters by tag: mark classes or methods @Category(SlowTests.class) and add @IncludeCategory/@ExcludeCategory to the suite class.

open as a page

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?

level: middleimportance: should knowfreq 38%

basics

~20 s

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

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

A JUnit 4 test class declares three `@Rule` fields and one of them must set up before the others. What order does JUnit apply multiple rules in by default, and how do you make that order deterministic?

level: seniorimportance: should knowfreq 28%

basics

~20 s

By default the order across several rule fields depends on the JVM's reflection order and is undefined. Pin it with @Rule(order = N) (JUnit 4.13+), where a lower value is the outer rule that starts first, or by combining rules into one RuleChain field.

open as a page

A JUnit 4 test class is already annotated `@RunWith(Parameterized.class)`, but you also need a second framework's runner — say one that boots an application context, or one that initialises annotated mock fields. What constraint do you hit, and what are the ways around it?

level: seniorimportance: should knowfreq 34%

basics

~20 s

@RunWith takes exactly one runner and is not repeatable, and the runner owns the whole class lifecycle — so a class cannot have two. Work around it by replacing one runner with its rule equivalent (rules compose), by supplying a Parameterized runner factory, or by splitting the class.

open as a page

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.

level: seniorimportance: should knowfreq 30%

basics

~20 s

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

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

A legacy suite marks slow tests with JUnit 4's @Category(SlowTests.class) while newer tests use JUnit 5's @Tag("slow"). Running everything on the JUnit Platform, how can one filter exclude both groups, and what exactly does the vintage engine do with a JUnit 4 category?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

The vintage engine exposes each JUnit 4 @Category as a JUnit Platform tag whose name is the category class's fully qualified name. So excluding tags with an expression like "slow | com.acme.SlowTests" removes both the Jupiter-tagged and the category-marked tests in one filter.

open as a page

When would you write a custom JUnit 4 `Runner`, and what do you actually implement — what roles do `Description`, `RunNotifier` and `ParentRunner` play?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Runner is abstract with getDescription() (the tree of tests) and run(RunNotifier) (execute, firing started/finished/failure events). In practice you extend ParentRunner — implementing getChildren, describeChild, runChild — or subclass BlockJUnit4ClassRunner to tweak instance creation or validation.

open as a page

Across a large JUnit 4 suite, dozens of test classes need the same expensive setup — a database container, a seeded workspace, a fixed clock. How do you weigh implementing that as a shared rule, an abstract base test class, or a static singleton, and what are the failure modes of each?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Rules compose (a class can hold many), a base class does not (Java has one superclass) and hides setup in an invisible parent. Singletons give suite-wide reuse but no teardown or isolation. The usual answer is a rule that fronts a lazily started singleton, with a per-test rule restoring isolation.

open as a page