Mastering JUnit 5 @ExtendWith: The Ultimate Guide to the Jupiter Extension Model

In the evolution of Java testing, the transition from JUnit 4 to JUnit 5 (Jupiter) wasn’t just a version bump—it was a complete architectural overhaul. One of the most significant changes was the retirement of the rigid Runner and Rule API in favor of a flexible, modular Extension Model.

At the heart of this model lies the @ExtendWith annotation. In this comprehensive guide, we will explore why @ExtendWith is a gateway into a sophisticated ecosystem that boosts developer productivity, how to use it with industry-standard libraries like Mockito, and how to build your own custom extensions to automate repetitive testing logic.

What is JUnit 5 @ExtendWith?

In JUnit 5, the @ExtendWith annotation is the primary way to register one or more Extensions for a test class or a specific test method.

Think of an extension as a “plugin” for your tests. Instead of JUnit being a closed system, it provides Extension Points—specific moments in the test lifecycle (like before a test starts, after it fails, or when a parameter needs to be resolved). The @ExtendWith annotation tells JUnit: “Hey, use this specific class to intercept these lifecycle events for me.”

Key Advantages: Composition over Inheritance

  • True Composability: In JUnit 4, you were limited to a single @RunWith annotation. JUnit 5 allows you to stack as many @ExtendWith declarations as you need.
  • Granular Scope: You can apply extensions to an entire class, a nested inner class, or even a single @Test method.
  • Unified API: It eliminates the confusing distinction between Runners, MethodRules, and TestRules.

JUnit 5 Extension Lifecycle at a Glance

Understanding when your code executes is critical for debugging. Here is the conceptual flow of the Lifecycle Callbacks:

[ Test Discovery ]
       │
       ▼
[ BeforeAllCallback ] 
(Once per class)
       │
       ▼
[ BeforeEachCallback ] 
(Before every @Test)
       │
       ▼
[ BeforeTestExecutionCallback ] 
(Immediately before test method)
       │
       ▼
   ( @Test Method )
       │
       ▼
[ AfterTestExecutionCallback ] 
(Immediately after test method)
       │
       ▼
[ AfterEachCallback ] 
(After every @Test)
       │
       ▼
[ AfterAllCallback ] 
(Once per class)

JUnit 4 @RunWith vs. JUnit 5 @ExtendWith

FeatureJUnit 4 (@RunWith / @Rule)JUnit 5 (@ExtendWith)
CardinalityOnly one Runner per classMultiple extensions allowed
IntegrationDifficult to combine frameworksSeamlessly combine Mockito, Spring, etc.
Logic ReuseDone via Rules and Base ClassesDone via reusable Extension classes
ExampleMockitoJUnitRunner.classMockitoExtension.class

Integrating Mockito with @ExtendWith

The MockitoExtension automatically initializes mocks and manages the mock session lifecycle.

Step 1: Add the Dependency (Maven)

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.23.0</version>
    <scope>test</scope>
</dependency>

Step 2: Implement the Test

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldProcessPaymentSuccessfully() {
        when(paymentGateway.authorize(100.0)).thenReturn(true);
        boolean result = orderService.processOrder(100.0);
        assertTrue(result, "Payment should be authorized");
    }
}

Console Output:

[INFO] Scanning for projects...
[INFO] Running OrderServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.852 s - in OrderServiceTest

Power Through Composition: Multiple Extensions

Stacking extensions allows you to combine heavy lifting like Spring with mock management.

@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class UserProfileServiceTest {
    
    @Mock
    private ExternalService externalService;
    
    @Test
    void testWithSpringAndMockito() {
        // Test logic
    }
}

Console Output:

2024-05-20 10:00:01.452  INFO ... Starting UserProfileServiceTest
2024-05-20 10:00:03.120  INFO ... Started UserProfileServiceTest in 1.668 seconds
[INFO] Running UserProfileServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

Building Your Own Custom Extension

Example 1: Performance Tracker

Using the ExtensionContext.Store ensures your extension is thread-safe and scoped correctly.

public class PerformanceTrackerExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
    private static final String START_TIME = "START_TIME";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;
        System.out.printf(">>> PERFORMANCE: Test [%s] took %d ms%n", context.getDisplayName(), duration);
    }

    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod()));
    }
}

Console Output (when applied to a test):

>>> PERFORMANCE: Test [myTest()] took 42 ms
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

Example 2: Database Transaction & Clean-up

In enterprise apps, you often want to wrap tests in a transaction that rolls back automatically to keep the DB clean.

public class TransactionalExtension implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) {
        System.out.println(">>> DB: Starting Transaction for " + context.getDisplayName());
        // Logic to begin DB transaction
    }

    @Override
    public void afterEach(ExtensionContext context) {
        System.out.println(">>> DB: Rolling back Transaction for " + context.getDisplayName());
        // Logic to rollback DB transaction
    }
}

Usage & Output:

@ExtendWith(TransactionalExtension.class)
class UserRepositoryTest {
    @Test
    void testUserSave() {
        System.out.println("Executing: Saving User to DB");
    }
}

Console Output:

>>> DB: Starting Transaction for testUserSave()
Executing: Saving User to DB
>>> DB: Rolling back Transaction for testUserSave()
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

Advanced: Programmatic Registration with @RegisterExtension

@RegisterExtension is used when you need to pass dynamic data to an extension that can’t be handled via class-level annotations.

class FileSystemTest {

    @RegisterExtension
    static TempDirectoryExtension tempDir = new TempDirectoryExtension("/custom/path/logs");

    @Test
    void testLogCreation() {
        System.out.println("Using directory: " + tempDir.getPath());
    }
}

Console Output:

>>> EXTENSION: Initializing directory at /custom/path/logs
Using directory: /custom/path/logs
>>> EXTENSION: Cleaning up directory at /custom/path/logs
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

Conclusion: Your Learning Journey

  1. Start Simple: Use MockitoExtension to eliminate setup boilerplate.
  2. Combine Forces: Use multiple @ExtendWith declarations for integration tests.
  3. Go Programmatic: Use @RegisterExtension for dynamic configurations.
  4. Innovate: Build custom extensions for shared concerns like Security Context Injection, Database Clean-up, or API Logging.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.