skip to content

How does JUnit 5's extension model (@ExtendWith) differ from JUnit 4's @RunWith and Rules, and why is it considered an improvement?

level: seniorimportance: should knowfreq 45%

answer

  1. one @RunWith max → many @ExtendWith
  2. Rules wrapped; extensions hook precise callbacks
  3. ParameterResolver injects method args (new)
  4. @RegisterExtension for stateful/programmatic
  5. meta-annotations bundle extensions

basics

~20 s

JUnit 4 customized test behavior with @RunWith (only one runner per class) and @Rule objects. JUnit 5 replaces both with extensions registered via @ExtendWith, and you can apply as many as you want, composing them freely.

solid answer

~50 s

In JUnit 4 you customized execution two ways. @RunWith installed a custom Runner — but a class can have only **one**, so you couldn't, say, combine the Spring runner and the Mockito runner. @Rule/@ClassRule injected reusable behavior (temp folders, timeouts) by wrapping the test, which was more flexible but ran at a fixed point in the lifecycle. JUnit 5 unifies both into a single **Extension** concept registered with @ExtendWith (or @RegisterExtension for programmatic/stateful ones). An extension implements small callback interfaces — BeforeEachCallback, AfterEachCallback, ParameterResolver, TestExecutionExceptionHandler, and so on — so it can hook into precisely the lifecycle points it needs. Crucially you can stack **many** extensions on one class, and they compose. This is the 'composition over a single runner' improvement: SpringExtension, MockitoExtension, and your own can all coexist, and meta-annotations let you bundle them into one custom annotation.

code

java · 20 lines
java
// JUnit 4: can't combine two runners — you must choose one.
// @RunWith(SpringRunner.class) // OR MockitoJUnitRunner, not both

// JUnit 5: stack as many extensions as needed.
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock Repo repo;            // supplied by MockitoExtension

    @Test
    void writesToTemp(@TempDir Path dir, TestInfo info) {
        // @TempDir and TestInfo injected via ParameterResolver extensions
    }
}

// Bundle into one reusable meta-annotation:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME)
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
@interface IntegrationTest {}

go deeper

for a junior

Know that @RunWith/@Rule are JUnit 4 and @ExtendWith is the JUnit 5 replacement; recognize @ExtendWith(MockitoExtension.class).

for a middle

Explain the one-runner limit, that extensions can be stacked, and map common runners/rules to their extension equivalents.

for a senior

Detail the callback interfaces (ParameterResolver, lifecycle callbacks, exception handler), the three registration mechanisms, and why composition beats a single runner.

for a principal

Reason about the extension SPI as a design boundary: meta-annotation bundling, ordering/interaction of multiple extensions, and migrating a custom JUnit 4 runner to an engine-level extension.

## Background: customizing test execution Sometimes a test needs extra behavior around it — inject a mock, start a Spring context, create a temp directory, retry on failure. Both JUnit versions let you *plug in* such behavior, but very differently. ## JUnit 4's two mechanisms ### `@RunWith(SomeRunner.class)` A **Runner** is the object that actually executes a test class. `@RunWith` swaps in a custom one (e.g. `SpringJUnit4ClassRunner`, `MockitoJUnitRunner`, `Parameterized`). The fatal limit: **a class can have exactly one runner**. If two libraries both need a runner, you can't use both — you have to pick, and fall back to lesser integration for the other. ### `@Rule` / `@ClassRule` A **Rule** is a field implementing `TestRule` (or `MethodRule`) that *wraps* each test method (think of it as decorating the test with before/after logic). Examples: `TemporaryFolder`, `Timeout`, `ExpectedException`. Rules compose better than runners (you can have several), but they're limited to *wrapping* the test — they can't, for instance, resolve a parameter to inject into a test method, and `@Rule` vs `@ClassRule` split per-test vs per-class into two annotations. ## JUnit 5's unified Extension model JUnit 5 replaces *both* with one concept: the **Extension**. An extension is a class implementing one or more fine-grained **callback interfaces**, each tied to a specific lifecycle point: - `BeforeAllCallback` / `AfterAllCallback` — around the whole class - `BeforeEachCallback` / `AfterEachCallback` — around each test - `BeforeTestExecutionCallback` / `AfterTestExecutionCallback` — tightest wrap around the test body - `ParameterResolver` — *supply* arguments to test/constructor parameters (this is new — runners/rules couldn't inject parameters) - `TestExecutionExceptionHandler` — intercept/swallow/transform thrown exceptions - `TestInstancePostProcessor`, `ExecutionCondition` (conditional disabling), and more. You register an extension three ways: - **`@ExtendWith(MyExtension.class)`** — declarative, on a class or method. - **`@RegisterExtension`** — on a *field*, for extensions that need configuration or hold state (programmatic). - **Automatic** — via Java's `ServiceLoader` (`/META-INF/services`), if globally enabled. ## Why it's an improvement 1. **No single-runner ceiling.** You can stack `@ExtendWith(SpringExtension.class)` *and* `@ExtendWith(MockitoExtension.class)` on the same class — impossible with two runners. 2. **Precise hooks.** An extension implements only the callbacks it needs, rather than re-implementing a whole runner or being stuck at the rule's wrap point. 3. **Parameter injection.** `ParameterResolver` lets the framework inject things (a `TestInfo`, a mock, a temp `Path`) directly into method parameters — a genuinely new capability. 4. **Composability via meta-annotations.** You can create a single custom annotation (e.g. `@IntegrationTest`) that is itself annotated with several `@ExtendWith`s and configuration, then apply that one annotation everywhere. ## Mapping table | JUnit 4 | JUnit 5 | |---|---| | `@RunWith(SpringJUnit4ClassRunner)` | `@ExtendWith(SpringExtension.class)` | | `@RunWith(MockitoJUnitRunner)` | `@ExtendWith(MockitoExtension.class)` | | `@Rule TemporaryFolder` | `@TempDir Path` (built-in extension) | | `@Rule ExpectedException` | `assertThrows(...)` | | only one `@RunWith` | as many `@ExtendWith` as you like | ## Mental model JUnit 4's runner was an all-or-nothing *replacement* of the engine for that class. JUnit 5's extensions are *small plug-ins* the engine invokes at named moments — like middleware in a web server: you register several, each does one thing, and they run in order.

  • When would you use @RegisterExtension instead of @ExtendWith?
    When the extension needs runtime configuration or holds state — @RegisterExtension goes on a field you instantiate yourself (e.g. a mock web server with a chosen port), whereas @ExtendWith only takes a class reference and can't be configured.
  • What capability do extensions have that JUnit 4 Rules fundamentally lacked?
    Parameter resolution — via ParameterResolver, an extension can inject arguments directly into test method (and constructor) parameters. Rules could only wrap the test; they couldn't supply method arguments.

JUnit 4's @RunWith is swapping the whole engine of the car — you only get one engine. JUnit 5 extensions are bolt-on accessories: install as many as you like, each doing one job.

saying these in an interview costs you the question

  • Saying you can have multiple @RunWith annotations (only one is allowed)
  • Claiming extensions are just a rename of Rules — they add parameter resolution and finer hooks
  • Forgetting @RegisterExtension exists for stateful/programmatic extensions
  • Thinking JUnit 5 still uses @RunWith (it's replaced by @ExtendWith)

context