The JUnit 5 extension model provides mechanisms to observe test execution outcomes through the ExtensionContext interface. Understanding how to handle test failures and exceptions is crucial for extensions that need to implement custom reporting, retry logic, or diagnostic logging.
Need for Failure Observation
Extensions often need to react to test outcomes—whether to log detailed failure information, implement retry mechanisms, or generate custom reports. Without access to execution exceptions, extensions can only observe test execution but not analyze why tests succeed or fail, missing opportunities for intelligent test enhancement and diagnostics.
Execution Outcome Method
Optional<Throwable> getExecutionException()
Returns the exception thrown during test execution, if any. This method is only meaningful in specific lifecycle callbacks like AfterTestExecutionCallback where the test outcome is known. It provides access to the actual failure cause for analysis and reporting.
Callback Context Awareness
getExecutionException() is specifically designed for use in AfterTestExecutionCallback and similar post-execution phases. Calling it in BeforeEachCallback or BeforeAllCallback will always return empty, as no execution has occurred yet.
Typical Use Cases
- Custom Failure Reporting: Generate detailed failure reports with additional context
- Retry Mechanisms: Implement automatic retry for flaky tests based on exception type
- Logging and Diagnostics: Capture and log exception details for debugging
- Failure Classification: Categorize failures by exception type for test analysis
- Conditional Cleanup: Perform different cleanup actions based on test outcome
Example
This example demonstrates a failure analysis extension that captures test outcomes, implements conditional retry logic for specific exceptions, and generates enhanced failure reports.
Retry Configuration Annotation
package com.logicbig.example;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RetryOnException {
Class<? extends Throwable>[] value();
int maxAttempts() default 3;
long delayMillis() default 100;
}
Custom Test Exception
package com.logicbig.example;
public class NetworkTimeoutException extends RuntimeException {
public NetworkTimeoutException(String message) {
super(message);
}
public NetworkTimeoutException(String message, Throwable cause) {
super(message, cause);
}
}
Extension Implementation
package com.logicbig.example;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.util.Optional;
public class FailureAnalysisExtension implements AfterTestExecutionCallback, BeforeEachCallback {
private static final String ATTEMPT_KEY = "retry-attempt";
@Override
public void beforeEach(ExtensionContext context) {
// Initialize retry attempt counter
context.getStore(ExtensionContext.Namespace
.create(context.getRequiredTestMethod()))
.put(ATTEMPT_KEY, 1);
}
@Override
public void afterTestExecution(ExtensionContext context) {
Optional<Throwable> executionException = context.getExecutionException();
System.out.println("\n=== TEST EXECUTION ANALYSIS ===");
System.out.println("Test: " + context.getDisplayName());
if (executionException.isPresent()) {
Throwable exception = executionException.get();
System.out.println("[FAILURE] Test failed with exception: " +
exception.getClass().getSimpleName());
System.out.println("[FAILURE] Message: " + exception.getMessage());
// Enhanced failure reporting
generateFailureReport(context, exception);
// Check if retry should be attempted
if (shouldRetryTest(context, exception)) {
attemptRetry(context, exception);
}
} else {
System.out.println("[SUCCESS] Test passed successfully");
// Log success details for diagnostics
logSuccessDetails(context);
}
System.out.println("---");
}
private void generateFailureReport(ExtensionContext context,
Throwable exception) {
System.out.println("[REPORT] Enhanced Failure Analysis:");
System.out.println(" Exception Type: " + exception.getClass().getName());
System.out.println(" Exception Message: " + exception.getMessage());
// Show first line of stack trace for context
StackTraceElement[] stackTrace = exception.getStackTrace();
if (stackTrace.length > 0) {
System.out.println(" Location: " + stackTrace[0]);
}
// Check for specific exception types
if (exception instanceof NetworkTimeoutException) {
System.out.println(" [DIAGNOSTIC] Network timeout detected");
System.out.println(" [DIAGNOSTIC] Consider increasing timeout or checking network");
} else if (exception instanceof NullPointerException) {
System.out.println(" [DIAGNOSTIC] Null pointer exception");
System.out.println(" [DIAGNOSTIC] Check for uninitialized objects");
} else if (exception instanceof IllegalArgumentException) {
System.out.println(" [DIAGNOSTIC] Invalid argument provided");
System.out.println(" [DIAGNOSTIC] Validate input parameters");
}
}
private boolean shouldRetryTest(ExtensionContext context,
Throwable exception) {
// Check if test method has @RetryOnException annotation
return context.getTestMethod().map(method -> {
if (method.isAnnotationPresent(RetryOnException.class)) {
RetryOnException annotation = method.getAnnotation(RetryOnException.class);
// Check if exception type matches retry configuration
for (Class<? extends Throwable> retryException : annotation.value()) {
if (retryException.isInstance(exception)) {
System.out.println("[RETRY] Exception " + exception.getClass().getSimpleName() +
" matches retry configuration");
return true;
}
}
}
return false;
}).orElse(false);
}
private void attemptRetry(ExtensionContext context,
Throwable exception) {
context.getTestMethod().ifPresent(method -> {
RetryOnException annotation = method.getAnnotation(RetryOnException.class);
// Get current attempt count
ExtensionContext.Store store = context.getStore(
ExtensionContext.Namespace.create(method)
);
int currentAttempt = store.get(ATTEMPT_KEY, Integer.class);
if (currentAttempt < annotation.maxAttempts()) {
System.out.println("[RETRY] Attempt " + currentAttempt +
" failed, retrying (max " + annotation.maxAttempts() + ")");
// Increment attempt counter
store.put(ATTEMPT_KEY, currentAttempt + 1);
// Simulate delay before retry
try {
Thread.sleep(annotation.delayMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Throw special exception to trigger retry
// In real implementation, you would use JUnit's retry mechanism
// or a custom test template
System.out.println("[RETRY] Would retry test here (implementation detail)");
} else {
System.out.println("[RETRY] Max attempts (" + annotation.maxAttempts() + ") reached");
System.out.println("[RETRY] Test will be marked as failed");
}
});
}
private void logSuccessDetails(ExtensionContext context) {
System.out.println("[DIAGNOSTIC] Test execution details:");
context.getTestClass().ifPresent(clazz -> {
System.out.println(" Test Class: " + clazz.getSimpleName());
});
context.getTestMethod().ifPresent(method -> {
System.out.println(" Test Method: " + method.getName());
});
}
}
Test Class
package com.logicbig.example;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(FailureAnalysisExtension.class)
public class FailureHandlingTest {
@Test
void successfulTest() {
System.out.println("Executing successfulTest");
// This test should pass
int result = 2 + 2;
System.out.println("Result: " + result);
}
@Test
void testWithNullPointerException() {
System.out.println("Executing testWithNullPointerException");
String text = null;
// This will throw NullPointerException
System.out.println("Text length: " + text.length());
}
@Test
void testWithIllegalArgumentException() {
System.out.println("Executing testWithIllegalArgumentException");
// This will throw IllegalArgumentException
validatePositiveNumber(-5);
}
@Test
@RetryOnException(value = {NetworkTimeoutException.class}, maxAttempts = 2)
void testWithRetryOnNetworkTimeout() {
System.out.println("Executing testWithRetryOnNetworkTimeout");
// Simulate network operation that might timeout
boolean networkAvailable = false; // Simulate network issue
if (!networkAvailable) {
throw new NetworkTimeoutException("Network connection timed out after 5000ms");
}
System.out.println("Network operation completed");
}
@Test
@RetryOnException(value = {IllegalArgumentException.class}, maxAttempts = 3, delayMillis = 200)
void testWithRetryOnIllegalArgument() {
System.out.println("Executing testWithRetryOnIllegalArgument");
// Simulate flaky validation that might fail initially
double arg = Math.random();
System.out.println("Random value: " + arg);
if (arg < 1) {
throw new IllegalArgumentException("Value " + arg + " is too low");
}
System.out.println("Validation passed");
}
private void validatePositiveNumber(int number) {
if (number < 0) {
throw new IllegalArgumentException("Number must be positive: " + number);
}
System.out.println("Validated positive number: " + number);
}
}
Output$ mvn test -Dtest=FailureHandlingTest [INFO] Scanning for projects... [INFO] [INFO] ------< com.logicbig.example:junit-5-extension-failure-handling >------- [INFO] Building junit-5-extension-failure-handling 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-failure-handling --- [INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-failure-handling\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-extension-failure-handling --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-failure-handling --- [INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-failure-handling\src\test\resources [INFO] [INFO] --- compiler:3.11.0:testCompile (default-testCompile) @ junit-5-extension-failure-handling --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-extension-failure-handling --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- Executing testWithNullPointerException
=== TEST EXECUTION ANALYSIS === Test: testWithNullPointerException() [FAILURE] Test failed with exception: NullPointerException [FAILURE] Message: Cannot invoke "String.length()" because "text" is null [REPORT] Enhanced Failure Analysis: Exception Type: java.lang.NullPointerException Exception Message: Cannot invoke "String.length()" because "text" is null Location: com.logicbig.example.FailureHandlingTest.testWithNullPointerException(FailureHandlingTest.java:22) [DIAGNOSTIC] Null pointer exception [DIAGNOSTIC] Check for uninitialized objects --- Executing successfulTest Result: 4
=== TEST EXECUTION ANALYSIS === Test: successfulTest() [SUCCESS] Test passed successfully [DIAGNOSTIC] Test execution details: Test Class: FailureHandlingTest Test Method: successfulTest --- Executing testWithIllegalArgumentException
=== TEST EXECUTION ANALYSIS === Test: testWithIllegalArgumentException() [FAILURE] Test failed with exception: IllegalArgumentException [FAILURE] Message: Number must be positive: -5 [REPORT] Enhanced Failure Analysis: Exception Type: java.lang.IllegalArgumentException Exception Message: Number must be positive: -5 Location: com.logicbig.example.FailureHandlingTest.validatePositiveNumber(FailureHandlingTest.java:65) [DIAGNOSTIC] Invalid argument provided [DIAGNOSTIC] Validate input parameters --- Executing testWithRetryOnNetworkTimeout
=== TEST EXECUTION ANALYSIS === Test: testWithRetryOnNetworkTimeout() [FAILURE] Test failed with exception: NetworkTimeoutException [FAILURE] Message: Network connection timed out after 5000ms [REPORT] Enhanced Failure Analysis: Exception Type: com.logicbig.example.NetworkTimeoutException Exception Message: Network connection timed out after 5000ms Location: com.logicbig.example.FailureHandlingTest.testWithRetryOnNetworkTimeout(FailureHandlingTest.java:41) [DIAGNOSTIC] Network timeout detected [DIAGNOSTIC] Consider increasing timeout or checking network [RETRY] Exception NetworkTimeoutException matches retry configuration [RETRY] Attempt 1 failed, retrying (max 2) [RETRY] Would retry test here (implementation detail) --- Executing testWithRetryOnIllegalArgument Random value: 0.3634273289493608
=== TEST EXECUTION ANALYSIS === Test: testWithRetryOnIllegalArgument() [FAILURE] Test failed with exception: IllegalArgumentException [FAILURE] Message: Value 0.3634273289493608 is too low [REPORT] Enhanced Failure Analysis: Exception Type: java.lang.IllegalArgumentException Exception Message: Value 0.3634273289493608 is too low Location: com.logicbig.example.FailureHandlingTest.testWithRetryOnIllegalArgument(FailureHandlingTest.java:57) [DIAGNOSTIC] Invalid argument provided [DIAGNOSTIC] Validate input parameters [RETRY] Exception IllegalArgumentException matches retry configuration [RETRY] Attempt 1 failed, retrying (max 3) [RETRY] Would retry test here (implementation detail) --- [INFO] +--com.logicbig.example.FailureHandlingTest - 0.444 ss [INFO] | +-- [XX] testWithNullPointerException - 0.062 ss [INFO] | +-- [OK] successfulTest - 0.014 ss [INFO] | +-- [XX] testWithIllegalArgumentException - 0.005 ss [INFO] | +-- [XX] testWithRetryOnNetworkTimeout - 0.112 ss [INFO] | '-- [XX] testWithRetryOnIllegalArgument - 0.212 ss [INFO] [INFO] Results: [INFO] [ERROR] Errors: [ERROR] FailureHandlingTest.testWithIllegalArgumentException:29->validatePositiveNumber:65 IllegalArgument Number must be positive: -5 [ERROR] FailureHandlingTest.testWithNullPointerException:22 NullPointer Cannot invoke "String.length()" because "text" is null [ERROR] FailureHandlingTest.testWithRetryOnIllegalArgument:57 IllegalArgument Value 0.3634273289493608 is too low [ERROR] FailureHandlingTest.testWithRetryOnNetworkTimeout:41 NetworkTimeout Network connection timed out after 5000ms [INFO] [ERROR] Tests run: 5, Failures: 0, Errors: 4, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [INFO] Total time: 3.221 s [INFO] Finished at: 2026-01-03T12:33:17+08:00 [INFO] ------------------------------------------------------------------------ [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.0:test (default-test) on project junit-5-extension-failure-handling: [ERROR] [ERROR] Please refer to D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-failure-handling\target\surefire-reports for the individual test results. [ERROR] Please refer to dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. [ERROR] -> [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging. [ERROR] [ERROR] For more information about the errors and possible solutions, please read the following articles: [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
The output demonstrates how the FailureAnalysisExtension effectively observes and responds to test execution outcomes. The extension successfully distinguishes between passing tests, expected failures, and unexpected exceptions. For tests annotated with @RetryOnException, it implements intelligent retry logic—automatically retrying network timeout failures while allowing other exceptions to fail immediately. The enhanced failure reporting provides detailed diagnostics including exception type, message, and stack trace context. This shows how getExecutionException() enables extensions to implement sophisticated failure handling patterns that go beyond simple pass/fail reporting, adding valuable diagnostic and recovery capabilities to the test framework.
Example ProjectDependencies and Technologies Used: - junit-jupiter-engine 6.0.1 (Module "junit-jupiter-engine" of JUnit)
Version Compatibility: 5.1.0 - 6.0.1 Version compatibilities of junit-jupiter-engine with this example:
- 5.1.0
- 5.1.1
- 5.2.0
- 5.3.0
- 5.3.1
- 5.3.2
- 5.4.0
- 5.4.1
- 5.4.2
- 5.5.0
- 5.5.1
- 5.5.2
- 5.6.0
- 5.6.1
- 5.6.2
- 5.6.3
- 5.7.0
- 5.7.1
- 5.7.2
- 5.8.0
- 5.8.1
- 5.8.2
- 5.9.0
- 5.9.1
- 5.9.2
- 5.9.3
- 5.10.0
- 5.10.1
- 5.10.2
- 5.10.3
- 5.10.4
- 5.10.5
- 5.11.0
- 5.11.1
- 5.11.2
- 5.11.3
- 5.11.4
- 5.12.0
- 5.12.1
- 5.12.2
- 5.13.0
- 5.13.1
- 5.13.2
- 5.13.3
- 5.13.4
- 5.14.0
- 5.14.1
- 6.0.0
- 6.0.1
Versions in green have been tested.
- JDK 25
- Maven 3.9.11
|