Retry Failed Tests in JUnit 5 (Without Losing Your Mind)
A few days ago, I found myself squinting at yet another flaky test report. You know the kind – 98% pass rate locally, but as soon as the CI wakes up, that one test decides to go hiking in the Alps and fail without remorse.
So I asked myself: How hard can it be to retry failed tests in JUnit 5?
Turns out, not hard. But not exactly obvious either. Let’s fix that.
☕ A Quick Note on Retrying Tests (and Why You Should Be Cautious)
Before we dive in, a brief PSA: retrying tests is a band-aid, not a cure. Flaky tests are usually a sign of timing issues, external dependencies, or concurrency bugs. That said, sometimes retries are useful – like with network-heavy integration tests or randomly failing Selenium setups – especially when you’re trying to keep CI green until you fix the real issue.
Let’s implement it cleanly.
🔧 Retrying Tests in JUnit 5
JUnit 5 doesn’t ship with built-in retry support. But the framework is extensible – so we’ll write a custom InvocationInterceptor
combined with an Annotation
to achieve retry logic.
Step 1: Create the Retry Annotation
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int value() default 3;
}
Step 2: Implement the Extension
import org.junit.jupiter.api.extension.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class RetryExtension implements InvocationInterceptor {
private static final int MAX_RETRIES = 3;
@Override
public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext) throws Throwable {
Method testMethod = invocationContext.getExecutable();
Object testInstance = extensionContext.getRequiredTestInstance();
Throwable lastThrowable = null;
int maxRetries = testMethod.getAnnotation(Retry.class) != null
? testMethod.getAnnotation(Retry.class).value()
: MAX_RETRIES;
for (int i = 0; i <= maxRetries; i++) {
try {
if (i == 0) {
// First attempt: Let JUnit handle it
invocation.proceed();
} else {
// Retry: manual reflection invocation
testMethod.setAccessible(true); // just in case
testMethod.invoke(testInstance);
}
return; // test passed
} catch (InvocationTargetException ite) {
lastThrowable = ite.getTargetException();
System.out.printf("Attempt %d failed: %s%n", attempt, lastThrowable.getMessage());
} catch (Throwable t) {
lastThrowable = t;
System.out.printf("Attempt %d failed: %s%n", attempt, t.getMessage());
}
}
// Throw last caught error after all retries
if (lastThrowable != null) {
throw lastThrowable;
} else {
throw new RuntimeException("Test failed after retries for unknown reason.");
}
}
}
Step 3: Use It in a Test
@ExtendWith(RetryExtension.class)
public class MyTestCases {
void flakyTest() {
// Will be repeated up to 3 times, defined in RetryExtension
}
@Retry(10)
void veryFlakyTest() {
// Will be repeated up to 10 times
}
}
These tests will retry up to 3 (flakyTest) or 10 (veryFlakyTest) times before finally giving up. If it passes on any attempt, it’s reported as a success.
Keep It Clean
Be careful not to abuse this. Retries should be used selectively, and ideally surrounded by logging or metrics so you’re aware they’re happening.
Just don’t forget: Flaky tests don’t belong in production pipelines. But a retry is fine while you’re hunting them down.