View Javadoc
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   * Attempts a test multiple times until it passes or the max amount of attempts is reached (default is 3).
30   *
31   * This is an attempt to replace our old retry rule from junit 4.
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      /* Max attempts */
41      int value() default 3;
42  
43      /**
44       * JUnit extension to retry failed test attempts
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              // check to see if the method is annotated with @TestAttempts
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                  // trick junit into not failing the test by aborting the attempt
91                  throw new TestAbortedException(
92                          String.format("Test attempt %d of %d failed, retrying...", testAttempt.exceptions, testAttempt.maxAttempts), throwable);
93              } else {
94                  // all attempts failed, so fail the test
95                  throw new AssertionError(String.format("Test attempt %d of %d failed", testAttempt.exceptions, testAttempt.maxAttempts), throwable);
96              }
97          }
98  
99          /**
100          * This class stops returning iterations after the first passed test attempt or the max number of attempts is reached.
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              * Iterator has next on the following conditions 1. this is the first attempt, or 2. no attempt has passed, and we have
114              * not reached the max attempts
115              *
116              * @return true if there are more attempts, false otherwise
117              */
118             @Override
119             public boolean hasNext() {
120                 return isFirstAttempt() || (hasNoPassingAttempts() && hasMoreAttempts());
121             }
122 
123             /**
124              * Get the next TestTemplateInvocationContext
125              *
126              * @return TestTemplateInvocationContext
127              * @throws NoSuchElementException if there is not another item
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          * Represents the context of a single invocation of a test template.
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