Writing Your First JUnit Jupiter (JUnit 5) Extension

Last Updated:  April 28, 2025 | Published: April 28, 2025

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:

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.
  • 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:

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.

Key points in the code:

  1. Implement Callbacks: The class implements both BeforeTestExecutionCallback and AfterTestExecutionCallback.
  2. beforeTestExecution:
    • Gets the current high-resolution time using System.nanoTime().
    • Retrieves the Store specific to the current test method using context.getStore(...). We use a Namespace created from our extension class and the test method to ensure our START_TIME_KEY doesn’t clash with keys used by other extensions.
    • Puts the start time into the store.
  3. 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).
  4. 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:

When we run these tests, our console output (wherever SLF4J is configured to log) will include lines similar to this:

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

Key points in this implementation:

  1. TestExecutionExceptionHandler: The core interface allowing us to react to exceptions.
  2. Reflection (extractWebDriver): We inspect the fields of the test class instance (context.getRequiredTestInstance()) to find one assignable to WebDriver. We make it accessible (field.setAccessible(true)) in case it’s private. This approach assumes a convention (one WebDriver field per test class).
  3. TakesScreenshot Check: We safely check if the found WebDriver instance actually supports the TakesScreenshot capability before attempting to use it.
  4. 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 (using java.time for better date/time handling), ensures the output directory exists, and copies the file.
  5. Error Handling: Includes try-catch blocks for file I/O and potential reflection errors, logging issues rather than stopping the extension process.
  6. Re-throwing Exception: Critically, handleTestExecutionException re-throws the original throwable 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:

Now, when searchForProduct_SimulateFailure runs and throws NoSuchElementException, our ScreenshotOnFailureExtension will:

  1. Catch the exception.
  2. Find the driver field in the ProductSearchTest instance.
  3. Take a screenshot.
  4. Save it to build/screenshots/failure-searchForProduct_SimulateFailure-YYYYMMDD_HHmmssSSS.png.
  5. Log the path to the console.
  6. 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

>