1 package emissary.test.core.junit5.extensions;
2
3 import org.junit.jupiter.api.TestTemplate;
4 import org.junit.jupiter.api.extension.ExtendWith;
5 import org.junit.jupiter.api.extension.ExtensionContext;
6 import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
7 import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
8 import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;
9 import org.junit.jupiter.api.parallel.Execution;
10 import org.junit.platform.commons.util.AnnotationUtils;
11 import org.junit.platform.commons.util.Preconditions;
12 import org.opentest4j.TestAbortedException;
13
14 import java.lang.annotation.Retention;
15 import java.lang.annotation.RetentionPolicy;
16 import java.lang.annotation.Target;
17 import java.util.Iterator;
18 import java.util.NoSuchElementException;
19 import java.util.Spliterator;
20 import java.util.Spliterators;
21 import java.util.stream.Stream;
22 import java.util.stream.StreamSupport;
23
24 import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
25 import static java.lang.annotation.ElementType.METHOD;
26 import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
27
28
29
30
31
32
33 @Target({METHOD, ANNOTATION_TYPE})
34 @Retention(RetentionPolicy.RUNTIME)
35 @Execution(SAME_THREAD)
36 @ExtendWith(TestAttempts.AttemptTestExtension.class)
37 @TestTemplate
38 public @interface TestAttempts {
39
40
41 int value() default 3;
42
43
44
45
46 class AttemptTestExtension implements TestTemplateInvocationContextProvider, TestExecutionExceptionHandler {
47
48 protected static final ExtensionContext.Namespace EXTENSION_CONTEXT_NAMESPACE = ExtensionContext.Namespace.create(AttemptTestExtension.class);
49
50 @Override
51 public boolean supportsTestTemplate(ExtensionContext context) {
52
53 return AnnotationUtils.isAnnotated(context.getRequiredTestMethod(), TestAttempts.class);
54 }
55
56 @Override
57 public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
58 return StreamSupport.stream(splitTestTemplateInvocationContexts(context), false);
59 }
60
61 @Override
62 public void handleTestExecutionException(ExtensionContext context, Throwable throwable) {
63 handleTestAttemptFailure(context.getParent().orElseThrow(() -> new UnsupportedOperationException("No template context found")),
64 throwable);
65 }
66
67 protected static Spliterator<TestTemplateInvocationContext> splitTestTemplateInvocationContexts(ExtensionContext context) {
68 return Spliterators.spliteratorUnknownSize(getTestTemplateInvocationContextProvider(context), Spliterator.ORDERED);
69 }
70
71 protected static AcceptFirstPassingAttempt getTestTemplateInvocationContextProvider(ExtensionContext context) {
72 ExtensionContext.Store store = context.getStore(EXTENSION_CONTEXT_NAMESPACE);
73 String key = context.getRequiredTestMethod().toString();
74 return store.getOrComputeIfAbsent(key, k -> createTestTemplateInvocationContextProvider(context), AcceptFirstPassingAttempt.class);
75 }
76
77 protected static AcceptFirstPassingAttempt createTestTemplateInvocationContextProvider(ExtensionContext context) {
78 TestAttempts retryTest = AnnotationUtils.findAnnotation(context.getRequiredTestMethod(), TestAttempts.class)
79 .orElseThrow(() -> new UnsupportedOperationException("Missing @TestAttempts annotation."));
80 int maxAttempts = retryTest.value();
81 Preconditions.condition(maxAttempts > 0, "Total test attempts need to be greater than 0");
82 return new AcceptFirstPassingAttempt(maxAttempts);
83 }
84
85 protected void handleTestAttemptFailure(ExtensionContext context, Throwable throwable) {
86 AcceptFirstPassingAttempt testAttempt = getTestTemplateInvocationContextProvider(context);
87 testAttempt.failed();
88
89 if (testAttempt.hasNext()) {
90
91 throw new TestAbortedException(
92 String.format("Test attempt %d of %d failed, retrying...", testAttempt.exceptions, testAttempt.maxAttempts), throwable);
93 } else {
94
95 throw new AssertionError(String.format("Test attempt %d of %d failed", testAttempt.exceptions, testAttempt.maxAttempts), throwable);
96 }
97 }
98
99
100
101
102 private static class AcceptFirstPassingAttempt implements Iterator<TestTemplateInvocationContext> {
103
104 protected final int maxAttempts;
105 protected int attempts;
106 protected int exceptions;
107
108 private AcceptFirstPassingAttempt(int maxAttempts) {
109 this.maxAttempts = maxAttempts;
110 }
111
112
113
114
115
116
117
118 @Override
119 public boolean hasNext() {
120 return isFirstAttempt() || (hasNoPassingAttempts() && hasMoreAttempts());
121 }
122
123
124
125
126
127
128
129 @Override
130 public TestTemplateInvocationContext next() {
131 if (!hasNext()) {
132 throw new NoSuchElementException();
133 }
134
135 ++attempts;
136 return new TestAttemptsInvocationContext(maxAttempts);
137 }
138
139 void failed() {
140 ++exceptions;
141 }
142
143 boolean isFirstAttempt() {
144 return attempts == 0;
145 }
146
147 boolean hasNoPassingAttempts() {
148 return attempts == exceptions;
149 }
150
151 boolean hasMoreAttempts() {
152 return attempts != maxAttempts;
153 }
154 }
155
156
157
158
159 static class TestAttemptsInvocationContext implements TestTemplateInvocationContext {
160
161 final int maxAttempts;
162
163 public TestAttemptsInvocationContext(int maxAttempts) {
164 this.maxAttempts = maxAttempts;
165 }
166
167 @Override
168 public String getDisplayName(int invocationIndex) {
169 return "Attempt " + invocationIndex + " of " + maxAttempts;
170 }
171 }
172 }
173 }
174