JUnit 5, also known as JUnit Jupiter (JUnit 5 = JUnit Jupiter + JUnit Platform + JUnit Vintage), brought a significant overhaul to the JUnit ecosystem. One of the most powerful features introduced is its comprehensive Extension API. If we’ve worked with JUnit 4, we might remember Runners like SpringJUnit4ClassRunner
or MockitoJUnitRunner
.
While effective, the Runner model had limitations, primarily allowing only one Runner per test class. JUnit Jupiter replaces this monolithic approach with a flexible and composable Extension model, opening up new possibilities for customizing the test lifecycle and integrating with other tools.
This article explores the JUnit Jupiter Extension API, discusses why and when we should leverage it, and walks through creating our first practical extension: capturing Selenium browser screenshots on test failures.
What is the JUnit Jupiter Extension API?
Think of the Extension API as the modern successor to JUnit 4’s Runners and Rules.
Instead of a single, often bulky, Runner controlling the entire test execution, JUnit Jupiter allows us to register multiple, focused Extensions.
- Modular: Extensions are small, targeted pieces of code that hook into specific points of the test execution lifecycle.
- Composable: We can apply multiple extensions to a single test class or method, combining their functionalities.
- Extensible: It provides a defined set of interfaces (Extension APIs) that our custom code can implement.
- Core Idea: Extensions intercept the test execution flow at various “extension points” to add custom behavior before, during, or after tests run.
This shift from a single Runner to multiple Extensions makes test setup and integration cleaner and more maintainable.
Why and When Should We Use Extensions?
While JUnit Jupiter provides many built-in features, custom extensions become invaluable when we need to:
- Reduce Boilerplate: Encapsulate repetitive setup or teardown logic required by many tests (e.g., starting/stopping servers, initializing mock objects, setting up database states).
- Separate Concerns: Keep test logic focused on what is being tested, moving cross-cutting concerns (like logging, transaction management, or environment setup) into reusable extensions.
- Integrate with Frameworks/Libraries: Provide seamless integration between JUnit and other technologies. This is how libraries like Spring, Mockito, Selenium, or WireMock integrate with JUnit 5.
- Implement Custom Test Logic: Introduce unique behaviors like conditional test execution based on environment variables, custom parameter resolution, or specialized reporting.
- Handle Test Outcomes: Perform actions based on test results, such as logging detailed diagnostics on failure or cleaning up resources differently depending on success or failure.
Essentially, if we find ourselves writing the same setup, teardown, or conditional logic across multiple test classes, it’s a strong candidate for being refactored into a custom extension.
Understanding the JUnit Jupiter Extension API
The Extension API represents one of the most significant improvements in JUnit 5 over its predecessor. Unlike JUnit 4’s monolithic Runners that controlled the entire test execution, Jupiter Extensions use a more modular approach.
Key characteristics of the Extension API include:
- Composability: Multiple extensions can be used together
- Targeted intervention: Extensions can focus on specific lifecycle events
- Registration flexibility: Extensions can be registered programmatically or declaratively
The Extension API provides several extension points through interfaces:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Test instance lifecycle BeforeAllCallback // Executed before all tests BeforeEachCallback // Executed before each test AfterEachCallback // Executed after each test AfterAllCallback // Executed after all tests // Exception handling TestExecutionExceptionHandler // Handle exceptions during test execution // Conditional test execution ExecutionCondition // Determine if a test should be executed // Parameter resolution ParameterResolver // Provide parameters for test methods // Test instance factories TestInstanceFactory // Create test class instances TestInstancePostProcessor // Process test instances after creation |
Popular Examples: Integration Powerhouses
Several frameworks provide extensions that showcase the power of the JUnit Jupiter Extension API:
- Spring Framework (
SpringExtension
): This is perhaps the most widely used extension. It integrates the Spring TestContext Framework with JUnit Jupiter.- It manages the Spring
ApplicationContext
lifecycle for tests. - It enables dependency injection of Spring beans directly into our test classes using annotations like
@Autowired
. - It provides support for transactional tests via
@Transactional
.
- It manages the Spring
- Mockito (
MockitoExtension
): Initializes mocks annotated with@Mock
and injects them where needed, reducing boilerplate mock creation code. - WireMock (
WireMockExtension
or similar): Community or library-provided extensions often manage the lifecycle of a WireMock server, ensuring it starts before tests and stops afterward, potentially injecting the base URL into tests.
These examples demonstrate how extensions act as bridges, allowing JUnit Jupiter to work seamlessly with external tools and frameworks, making our testing experience smoother.
The SpringExtension
integrates the Spring TestContext Framework with JUnit Jupiter:
1 2 3 4 5 6 7 8 9 10 11 12 |
@ExtendWith(SpringExtension.class) @ContextConfiguration(classes = TestConfig.class) public class SpringIntegrationTest { @Autowired private SomeService service; @Test void testServiceOperation() { // Test using Autowired components } } |
Let’s Develop an Example: A Simple Test Timer
To see the Extension API in action, let’s build a common utility: an extension that logs the execution time of each test method. This can be helpful during development or debugging to pinpoint unexpectedly slow tests.
Important Note: While this extension is a great way to understand the lifecycle hooks and context store, remember that most IDEs (like IntelliJ IDEA, Eclipse) and build tools (Maven Surefire, Gradle) already provide detailed reports on test execution times. This example is primarily for educational purposes or very specific diagnostic scenarios.
Goal: Log the duration of each test method’s execution in milliseconds.
Choosing the Hooks: We want to measure the time spent inside the test method itself, excluding BeforeEach
/AfterEach
setup/teardown. The perfect hooks for this are:
BeforeTestExecutionCallback
: Executes immediately before the test method body runs. We’ll record the start time here.AfterTestExecutionCallback
: Executes immediately after the test method body completes (successfully or with an exception). We’ll calculate and log the duration here.
To pass the start time from the before
callback to the after
callback, we’ll use the ExtensionContext.Store
. This is a thread-safe, hierarchical key-value store provided by JUnit Jupiter, designed precisely for sharing state between different callbacks related to the same test element (like a method or class).
Implementing our First JUnit Jupiter (JUnit 5) Extension
We create a class implementing both BeforeTestExecutionCallback
and AfterTestExecutionCallback
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import org.junit.jupiter.api.extension.AfterTestExecutionCallback; import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; class TestTimerExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { private static final Logger log = LoggerFactory.getLogger( TestTimerExtension.class ); // Key to store the start time in the ExtensionContext.Store private static final String START_TIME_KEY = "startTime"; @Override public void beforeTestExecution(ExtensionContext context) throws Exception { // Get the store associated with the current test method getStore(context).put(START_TIME_KEY, System.nanoTime()); } @Override public void afterTestExecution(ExtensionContext context) throws Exception { // Retrieve the start time from the store long startTime = getStore(context).remove(START_TIME_KEY, long.class); long durationNanos = System.nanoTime() - startTime; long durationMillis = durationNanos / 1_000_000; // Convert nanoseconds to milliseconds // Get the display name of the test method String testDisplayName = context.getDisplayName(); log.info( "Test [{}] took {} ms.", testDisplayName, durationMillis ); } /** * Helper method to get the ExtensionContext.Store for the current test method. * A Namespace prevents key collisions between different extensions. */ private ExtensionContext.Store getStore(ExtensionContext context) { // Use the test method as the scope for the store // Use this class as the namespace to avoid key conflicts return context.getStore( ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod()) ); } } |
Key points in the code:
- Implement Callbacks: The class implements both
BeforeTestExecutionCallback
andAfterTestExecutionCallback
. beforeTestExecution
:- Gets the current high-resolution time using
System.nanoTime()
. - Retrieves the
Store
specific to the current test method usingcontext.getStore(...)
. We use aNamespace
created from our extension class and the test method to ensure ourSTART_TIME_KEY
doesn’t clash with keys used by other extensions. - Puts the start time into the store.
- Gets the current high-resolution time using
afterTestExecution
:- Retrieves the
Store
again. - Uses
store.remove(START_TIME_KEY, long.class)
to get the start time and remove it from the store (good practice for cleanup). - Calculates the duration in nanoseconds and converts it to milliseconds.
- Gets the test’s display name (which includes parameters if used) via
context.getDisplayName()
. - Logs the result using SLF4J (or any logging framework).
- Retrieves the
getStore
Helper: This private method encapsulates the logic for retrieving the correct store with a proper namespace, making the callback methods cleaner.
Usage our First Extension
To use this extension, simply annotate our test class (or a specific method, or a base class) with @ExtendWith
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@ExtendWith(TestTimerExtension.class) class SimpleCalculationTest { @Test void quickTest() { assertEquals(4, 2 + 2); } @Test void slowTest() throws InterruptedException { // Simulate some work Thread.sleep(150); // Sleep for 150 milliseconds assertEquals(10, 5 * 2); } } |
When we run these tests, our console output (wherever SLF4J is configured to log) will include lines similar to this:
1 2 |
INFO com.mycompany.testing.extensions.TestTimerExtension - Test [Fast addition test] took 0 ms. INFO com.mycompany.testing.extensions.TestTimerExtension - Test [Slower test with sleep] took 152 ms. |
(The exact timing for the “fast” test might be 0 or 1 ms, and the “slow” test will be slightly over 150ms due to overhead).
This simple example effectively demonstrates how to hook into the test execution lifecycle and share state between callbacks using the ExtensionContext.Store
, forming the basis for many more complex and useful extensions.
Advanced Example: Creating a Selenium Screenshot Extension
Imagine we are writing UI tests using Selenium WebDriver. When a test fails, especially intermittently, debugging can be challenging. Was the wrong element clicked? Did a modal dialog pop up unexpectedly? Having a screenshot of the browser at the exact moment of failure provides invaluable visual context.
Let’s develop a practical extension that automatically captures a browser screenshot using Selenium WebDriver whenever a test method throws an exception.
Goal: Automatically take and save a screenshot if a test method fails, using the WebDriver
instance from the test class.
Choosing the Hook: Just like our earlier, simpler exception handling idea, the TestExecutionExceptionHandler
interface is the perfect fit. It’s invoked specifically when an exception is thrown during the execution of the test method itself.
Step 1: Define the Extension
We need a class that implements TestExecutionExceptionHandler
. This extension needs access to the WebDriver
instance associated with the test. A common pattern is to have the WebDriver
as a field within the test class. Our extension can then access the test instance via the ExtensionContext
and use reflection to find and retrieve the WebDriver
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
public class ScreenshotOnFailureExtension implements TestExecutionExceptionHandler { @Override public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable { // Get the WebDriver from the test instance Object testInstance = context.getRequiredTestInstance(); Optional<WebDriver> webDriver = extractWebDriver(testInstance); if (webDriver.isPresent()) { takeScreenshot(webDriver.get(), context.getRequiredTestMethod().getName()); } // Rethrow the exception to fail the test throw throwable; } private Optional<WebDriver> extractWebDriver(Object testInstance) { // Find WebDriver field in the test class Field[] fields = testInstance.getClass().getDeclaredFields(); return Arrays.stream(fields) .filter(field -> WebDriver.class.isAssignableFrom(field.getType())) .findFirst() .map(field -> { try { field.setAccessible(true); return (WebDriver) field.get(testInstance); } catch (IllegalAccessException e) { return null; } }); } private void takeScreenshot(WebDriver driver, String testName) { try { if (driver instanceof TakesScreenshot) { File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); String timestamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()); String filename = String.format("failure-%s-%s.png", testName, timestamp); Path destination = Paths.get("target", "screenshots", filename); Files.createDirectories(destination.getParent()); Files.copy(screenshot.toPath(), destination, StandardCopyOption.REPLACE_EXISTING); System.out.println("Screenshot saved to: " + destination.toAbsolutePath()); } } catch (Exception e) { System.err.println("Failed to capture screenshot: " + e.getMessage()); } } } |
Key points in this implementation:
TestExecutionExceptionHandler
: The core interface allowing us to react to exceptions.- Reflection (
extractWebDriver
): We inspect the fields of the test class instance (context.getRequiredTestInstance()
) to find one assignable toWebDriver
. We make it accessible (field.setAccessible(true)
) in case it’s private. This approach assumes a convention (oneWebDriver
field per test class). TakesScreenshot
Check: We safely check if the foundWebDriver
instance actually supports theTakesScreenshot
capability before attempting to use it.- Screenshot Logic (
takeScreenshot
): Uses Selenium’s API to get the screenshot as a file, generates a unique filename using the test name and a timestamp (usingjava.time
for better date/time handling), ensures the output directory exists, and copies the file. - Error Handling: Includes
try-catch
blocks for file I/O and potential reflection errors, logging issues rather than stopping the extension process. - Re-throwing Exception: Critically,
handleTestExecutionException
re-throws the originalthrowable
to ensure JUnit still registers the test failure.
Step 2: Use the Extension in Tests
Applying the extension is straightforward using the @ExtendWith
annotation on our Selenium test class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
@ExtendWith(ScreenshotOnFailureExtension.class) class ProductSearchTest { // The extension will find this field via reflection private WebDriver driver; @BeforeAll static void setupClass() { WebDriverManager.chromedriver().setup(); } @BeforeEach void setUp() { ChromeOptions options = new ChromeOptions(); driver = new ChromeDriver(options); driver.manage().window().maximize(); } @AfterEach void tearDown() { if (driver != null) { driver.quit(); } } @Test void searchForProduct_SimulateFailure() { driver.get("https://www.google.com"); driver.findElement(By.id("q")).sendKeys("JUnit Jupiter Extensions"); // Intentionally look for a non-existent element to cause failure WebElement nonExistentButton = driver.findElement( By.id("nonExistentSearchButtonId") ); nonExistentButton.click(); // This line will throw NoSuchElementException // Assertion will not be reached WebElement resultStats = driver.findElement(By.id("result-stats")); assertTrue( resultStats.isDisplayed(), "Search results stats should be visible" ); } } |
Now, when searchForProduct_SimulateFailure
runs and throws NoSuchElementException
, our ScreenshotOnFailureExtension
will:
- Catch the exception.
- Find the
driver
field in theProductSearchTest
instance. - Take a screenshot.
- Save it to
build/screenshots/failure-searchForProduct_SimulateFailure-YYYYMMDD_HHmmssSSS.png
. - Log the path to the console.
- Re-throw the exception, marking the test as failed.
This more advanced example showcases how extensions can integrate with external libraries, use reflection (carefully!), handle exceptions, and be made configurable using custom annotations, significantly enhancing our testing capabilities.
Summary
JUnit Jupiter Extensions provide a powerful mechanism for customizing test behavior and integrating with testing frameworks. By replacing the monolithic Runner approach of JUnit 4, Extensions offer greater flexibility and composability for our tests.
Key benefits of using Extensions include:
- Modular approach to test customization
- Multiple extension points in the test lifecycle
- Easy integration with frameworks and libraries
- Ability to extract cross-cutting concerns into reusable components
Our Selenium screenshot example demonstrates how Extensions can add practical value to everyday testing scenarios. By capturing screenshots when tests fail, we gain valuable diagnostic information that helps us identify and fix intermittent UI test failures more efficiently.
As we continue to develop our testing strategies, JUnit Jupiter Extensions should be an essential tool in our testing arsenal, helping us create more robust, maintainable, and informative tests.
Joyful testing,
Philip