Skip to content

Extending JUnit

Estimated time to read: 8 minutes

Extension Points

JUnit 5 was designed to prefer extension points over features. Allowing JUnit core to be as simple as possible whilst extensions provide required features.

JUnit Jupiter is based on interface extension.

JUnit provides many extension points during the lifecycle of a test. Each extension corresponds to an interface that extends from the interface extension.

If a class wants to extended the behaviour of a test, it must implement one of these interfaces.

Interface Categories

General Purpose

  • TestInstanceFactory - Creates test instances
  • TestInstancePostProcessor - Post-processes test instances in order to add dependencies or invoke custom initialisation methods
  • TestInstancePreDestroyCallback - Processes test instances after they have been used in tests, but before they have been destroyed
  • TestWatcher - Processes the results of the test method executions after a disable test method has been skipped, has completed successfully, aborted or failed
  • InvocationInteceptor - Intercepts calls to tests
  • TestTemplateInvocationContextProvider - For implementing different types of tests that rely on repetitive invocation of test methods in different contexts, for example, with different parameters.
  • ParameterResolver - Dynamically resolves and inject parameters to to test methods at runtime
  • TestExecutionExceptionHandler - Handles exceptions thrown during test execution

Conditional

  • ExecutionCondition - Evaluate if a given class or test method should be executed. This can be used as an alternative to the @Disabled annotation.

Lifecycle Callbacks

  • BeforeAllCallback / AfterAllCallback
  • BeforeEachCallback / AfterEachCallback
  • BeforeTestExecution / AfterTestExecution

Extension Registration

Extensions can be registered either:

  • Declaratively - with the @ExtendWith annotation. Annotating the class or the method where the extension is to be used.
  • Programmatically - by annotating class fields with @RegisterExtension
  • Automatically - with Java's java.util.ServiceLoader mechanism. The FQN of the Extension class can be declared in:
  • /MEDA-INF/services (org.junit.jupiter.api.extension.Extension)
  • Autodetection can be set via the JVM System Property junit.jupiter.extensions.autodetection.enabled with a boolean value

Example

LifecycleExtension.java
package io.entityfour.coffee;

import org.junit.jupiter.api.extension.*;

public class LifecycleExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback {
    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        System.out.println("beforeAllCallback");
    }

    @Override
    public void afterAll(ExtensionContext context) throws Exception {
        System.out.println("afterAllCallback");
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("beforeEachCallback");
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("afterEachCallback");
    }

    @Override
    public void beforeTestExecution(ExtensionContext context) throws Exception {
        System.out.println("beforeTestExecutionCallback");
    }

    @Override
    public void afterTestExecution(ExtensionContext context) throws Exception {
        System.out.println("afterTestExecutionCallback");
    }
}
RewardByConversionWithExtensionTest.java
// ...

@ExtendWith(LifecycleExtension.class) // @ExtendedWith will implement the overridden classes from the specified file to this class. This enables methods to be run before / after callbacks are executed. 
public class RewardByConversionWithExtensionTest {
    private RewardByConversionService reward;

  @BeforeAll
    static void setUpAll() {
        System.out.println("BeforeAll");
    }

    @BeforeEach
    void setUp() {
        System.out.println("BeforeEach");
        reward = new RewardByConversionService();
        reward.setNeededPoints(100);
        reward.setAmount(10);
    }

    @AfterEach
    void tearDown() {
        System.out.println("AfterEach");
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("AfterAll");
    }

    @Test
    @ExtendWith(LifecycleExtension.class)
    void changeAmount() {
        System.out.println("Test changeAmount");
        reward.setAmount(20);

        assertEquals(20, reward.getAmount());
    }

    @Test
    void rewardNotAppliedEmptyOrder() {
        RewardInformation info = reward.applyReward(
                new ArrayList<>(),
                500
        );

        assertEquals(0, info.getPointsRedeemed());
        assertEquals(0, info.getDiscount());
    }
}

Note

@ExtendedWith is aware if they are executed as a class or a method. An Extension can behave differently depending on the context in which it is applied.

Parameter Injection

JUnit can inject some types of parameters at runtime

  • Test information parameters of type:
  • RepetitionInfo
  • TestInfo
  • TestReporter
  • ParameterResolver
  • RepetitionInfoParameterResolver
  • TestInfoParameterResolver
  • TestReportedParameterResolver

Parameter Resolver

boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
 throws ParameterResolutionException

Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
 throws ParameterResolutionException

ParameterResolver can be used in methods that are annotated with @Test and @TestFactory. lifecycle methods annotated with @BeforeEach and @AfterEach, as well as class constructors.

Example

@ExtendWith(LifecycleExtension.class)
public class RewardByConversionWithExtensionTest {
    // private RewardByConversionService reward; 
   // Instead of having a reward service as an instance variable, this could be passed as a parameter of each test method.

  // This setUp block can be removed as this is now done within the parameters of the test method
  // @BeforeEach
  // void setUp() {
  //  System.out.println("BeforeEach");
  //  reward = new RewardByConversionService();
  //  reward.setNeededPoints(100);
  //  reward.setAmount(10);
  // }

    // void changeAmount(RewardByConversionService reward) {} 
  // This method creates a new instance of RewardByConversionService when the test is run

  @RegisterExtension
  RewardByConversionParameterResolver pr = new RewardByConversionParameterResolver(); // Add a field for the Extension, declared in 'RewardByConversionParameterResolver.java'. The field must not be private. 


  // ...

    @Test
    @ExtendWith(LifecycleExtension.class)
    void changeAmount(RewardByConversionService reward) {
        System.out.println("Test changeAmount");
        reward.setAmount(20);

        assertEquals(20, reward.getAmount());
    }

    @Test
    void rewardNotAppliedEmptyOrder(RewardByConversionService reward) {
        RewardInformation info = reward.applyReward(
                new ArrayList<>(),
                500
        );

        assertEquals(0, info.getPointsRedeemed());
        assertEquals(0, info.getDiscount());
    }
}
RewardByConversionParameterResolver.java
// ...

public class RewardByConversionParameterResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(
            ParameterContext parameterContext, ExtensionContext extensionContext)
            throws ParameterResolutionException
    {
        return parameterContext.getParameter().getType().equals(RewardByConversionService.class); // Check to see the type of the parameter via the getParameter().getType() method is equals to RewardByConversionService.
    }

    @Override
    public Object resolveParameter(
            ParameterContext parameterContext, ExtensionContext extensionContext)
            throws ParameterResolutionException
    {
        RewardByConversionService reward = new RewardByConversionService();
        reward.setNeededPoints(100);
        reward.setAmount(10);

        return reward;
    }
}

Meta-annotations

JUnit allows the creation of Meta-annotations. Meta-annotations are custom defined annotations that use one or more JUnit annotations and inherit their semantics.

Take the example below, assume that an extension is required to catch any exceptions that may be thrown.

Instead of adding @ExtendWith annotation to the method. It is possible to create a custom annotation. @TestWithErrorHandler. Meta-annotating this with @Test and @ExtendWith() will be the same as if they were used in the test method directly. Choosing the name correctly can greatly improve the readability of the code.

@Test
void testRewardProgram() {
 //
}
@Test
@ExtendWith({ExceptionHandler.class})
public @interface TestWithErrorHandler() {

}

@TestWithErrorHandler
void testRewardProgram() {
 //
} 

Example

IllegalArgumentExceptionHandlerExtension
public class IllegalArgumentExceptionHandlerExtension implements TestExecutionExceptionHandler {
 @Override
 public void handleTestExecutionException(ExtensionContext context, Throwable throwable) // Takes a parameter of type ExtensionContext and the Throwable.
  throws Throwable 
 {
  if (throwable instanceof IllegalArgumentException) {
   System.out.println("Exception of type IllegalArgumentException thrown by "
              +
              context.getRequiredTestClass().getName() // Get the class name that threw the exception
              +
              "#"
              +
              context.getRequiredTestMethod().getName() // Get the method name that threw the exception
  );
  return; // This will return the exception but will also allow other tests to continue
 }
}
TestWithErrorHandler.java
// ...

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith( // @ExtendWith can have an array passed to it with the extensions that are to be used
        {
                IllegalArgumentExceptionHandlerExtension.class,
                RewardByConversionParameterResolver.class
        }
)
@Test
public @interface TestWithErrorHandler {
}
RewardByConversionWithExtension.java
// ...

class RewardByConversionWithExtensionTest {

    @TestWithErrorHandler // Custom annotation 
    void changeAmount(RewardByConversionService reward) {
        reward.setAmount(-20);

        assertEquals(20, reward.getAmount());
    }

    @TestWithErrorHandler
    void rewardNotAppliedEmptyOrder(RewardByConversionService reward) {
        RewardInformation info = reward.applyReward(
                new ArrayList<>(),
                500
        );

        assertEquals(0, info.getPointsRedeemed());
        assertEquals(0, info.getDiscount());
    }
}

Keeping State

The JUnit Jupiter engine typically works with one instance of an Extension class. However, there is no gaurantee as to when an extension will be instantiated or how long it is kept by the engine. For this reason, Extensions must be designed to be stateless.

To store a state from one invocation to the next, it has to be written to a stored object that is provided by the extension context.

Store

  • A store has its own namespace in order to prevent collisions between different extensions. This namespace can be declared or the global namespace can be used.
  • Objects are stored within the store using a key-value structure
  • Hierarchy
  • Method Level
  • Class Level
  • Engine Level
  • A store is created for each of these contexts / levels within the hierarchy and each store has a reference to its parent. For example, a test method has a reference to the store of the class that contains that method.
  • A store can be queried for a value, if it is not found within that store, the parent store will be queried, using the same key name and within the same namespace.
  • When a value is written to a store, it is only written at one level.

ExtensionContext.Store

Object get(Object key)
<K,V> Object getOrComputeIfAbsent(K key, Function<K,V> defaultCreator)
void put(Object key, Object value)
Object remove(Object key)

The store is a key-value structure. It uses a map to hold its values, but is not a map itself.

It has methods to retrieve values such as get and getOrComputeIfAbsent. It also has methods to save and remove values such as put and remove.

Example

If a method throws the IllegalArgumentException exception, it is likely that the rest of the test method should be disabled in the test class.

The exception can be saved in the context store and then disable the execution of the current test there is an exception recorded in the store.

Rather than including all of this logic in one file, it is beneficial to break this down and take a more modular approach by creating another class specifically for this.

Namespace and Key creation

ExtensionUtils.class
// ...

public class ExtensionUtils {

 // Namespace creation
 public static final ExtensionContext.Namespace <NAMESPACE_NAME_HERE> = ExtensionContext.Namespace.create(
  "Custom", "Namespace"
 );

 // Adding a key to save the exception in the store
 public static final String EXCEPTION_KEY = "EXCEPTION";

 // Get the engineContext so other methods in the hierarchy can access the namespace
 public static ExtensionContext getEngineContext(ExtensionContext contextParam) {
  return contextParam.getRoot();
 }
}

Note

Note that Namespace is an inner class of ExtensionContext, which has a static method to create a Namespace object.

The Namespace.create method takes a variable number of items. Strings are used in the example above. However, this can be set to anything, as required.

The order of the object is important, inside of the Namespace, the objects are stored within an array list. To check if two name spaces are the same, the equals method of the array list is used.

Disabling Tests

DisableTestsIfExceptionThrownExtension.java
// ...

public class DisableTestsIfExceptionThrownExtension implements ExecutionCondition {

 @Override
 public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { // Takes ExtensionContext and returns an object of type ConditionEvaluationResult. This has two methods create results, one for enabled results and one for disabled results. 

  ConditionEvaluationResult result = ConditionEvaluationResult.enabled("No exception thrown"); // Return an enabled result

  Throwable t = (Throwable) context.getStore(NAMESPACE).get(EXCEPTION_KEY); // Retrieve the exception_key object value that has been stored in the store

  if(t != null) { // If the exception_key value is not null, throw the exception from the throwable object
   result = ConditionEvaluationResult.disabled("An exception was thrown: " + t.getMessage());
  }

  return result;
}

Code update

IllegalArgumentExceptionHandlerExtension
public class IllegalArgumentExceptionHandlerExtension implements TestExecutionExceptionHandler {

 @Override
 public void handleTestExecutionException (
     ExtensionContext context, Throwable throwable)
     throws Throwable
 {
  if(throwable instanceof IllegalArgumentException) {
   ExtensionContext engineContext = getEngineContext(context);
   //context.getStore(NAMESPACE).put(EXCEPTION_KEY, throwable); // Save the exception in the method store using a K:V pair
   engineContext.getStore(NAMESPACE).put(EXCEPTION_KEY, throwable); // Save the exception in the engine store using a K:V pair
   return;
  }
  throw throwable;
 }
}
TestWithErrorHandler.java
// ...

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith( // @ExtendWith can have an array passed to it with the extensions that are to be used
       {
        IllegalArgumentExceptionHandlerExtension.class,
        RewardByConversionParameterResolver.class,
        DisableTestsIfExceptionThrownExtension.class
       }
      )
@Test
public @interface TestWithErrorHandler {
}

Sample Extensions

JUnit comes with a built in Extension TempDirectory. It creates and cleans up a temporary directory if a non-private field or parameter is annotated with @TempDir.

See Third Party Extensions for more JUnit extensions