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¶
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");
}
}
// ...
@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());
}
}
// ...
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
@ExtendWith({ExceptionHandler.class})
public @interface TestWithErrorHandler() {
}
@TestWithErrorHandler
void testRewardProgram() {
//
}
Example¶
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
}
}
// ...
@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 {
}
// ...
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¶
// ...
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¶
// ...
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¶
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;
}
}
// ...
@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