Close

JUnit 5 - ExtensionContext - Handling Test Failures & Exceptions

[Last Updated: Jan 3, 2026]

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 Project

Dependencies and Technologies Used:

  • junit-jupiter-engine 6.0.1 (Module "junit-jupiter-engine" of JUnit)
     Version Compatibility: 5.1.0 - 6.0.1Version List
    ×

    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

JUnit 5 - Extension Context Handling Test Failures & Exceptions Select All Download
  • junit-5-extension-failure-handling
    • src
      • test
        • java
          • com
            • logicbig
              • example
                • FailureHandlingTest.java

    See Also