// =================================================================================================
// Copyright 2015 Twitter, Inc.
// -------------------------------------------------------------------------------------------------
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this work except in compliance with the License.
// You may obtain a copy of the License in the LICENSE file, or at:
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// =================================================================================================
package com.twitter.common.testing.junit.rules;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Objects;
import org.junit.rules.MethodRule;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.Statement;
/**
* A test method annotation useful for smoking out flaky behavior in tests.
*
* @see Retry.Rule RetryRule needed to enable this annotation in a test class.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
/**
* The number of times to retry the test.
*
* When a {@link Retry.Rule} is installed and a test method is annotated for {@literal @Retry},
* it will be retried 0 to N times. If times is negative, it is treated as 0 and no retries are
* performed. If times is >= 1 then a successful execution of the annotated test method is
* retried until the 1st error, failure or otherwise up to {@code times} times.
*/
int times() default 1;
/**
* Enables {@link Retry @Retry}able tests.
*/
class Rule implements MethodRule {
private interface ThrowableFactory {
Throwable create(String message, Throwable cause);
}
private static Throwable annotate(
int tryNumber,
final int maxRetries,
Throwable cause,
String prefix,
ThrowableFactory throwableFactory) {
Throwable annotated =
throwableFactory.create(
String.format("%s on try %d of %d: %s", prefix, tryNumber, maxRetries + 1,
Objects.firstNonNull(cause.getMessage(), "")), cause);
annotated.setStackTrace(cause.getStackTrace());
return annotated;
}
static class RetriedAssertionError extends AssertionError {
private final int tryNumber;
private final int maxRetries;
RetriedAssertionError(int tryNumber, int maxRetries, String message, Throwable cause) {
// We do a manual initCause here to be compatible with the Java 1.6 AssertionError
// constructors.
super(message);
initCause(cause);
this.tryNumber = tryNumber;
this.maxRetries = maxRetries;
}
@VisibleForTesting
int getTryNumber() {
return tryNumber;
}
@VisibleForTesting
int getMaxRetries() {
return maxRetries;
}
}
private static Throwable annotate(final int tryNumber, final int maxRetries, AssertionError e) {
return annotate(tryNumber, maxRetries, e, "Failure", new ThrowableFactory() {
@Override public Throwable create(String message, Throwable cause) {
return new RetriedAssertionError(tryNumber, maxRetries, message, cause);
}
});
}
static class RetriedException extends Exception {
private final int tryNumber;
private final int maxRetries;
RetriedException(int tryNumber, int maxRetries, String message, Throwable cause) {
super(message, cause);
this.tryNumber = tryNumber;
this.maxRetries = maxRetries;
}
@VisibleForTesting
int getTryNumber() {
return tryNumber;
}
@VisibleForTesting
int getMaxRetries() {
return maxRetries;
}
}
private static Throwable annotate(final int tryNumber, final int maxRetries, Exception e) {
return annotate(tryNumber, maxRetries, e, "Error", new ThrowableFactory() {
@Override public Throwable create(String message, Throwable cause) {
return new RetriedException(tryNumber, maxRetries, message, cause);
}
});
}
@Override
public Statement apply(final Statement statement, FrameworkMethod method, Object receiver) {
Retry retry = method.getAnnotation(Retry.class);
if (retry == null || retry.times() <= 0) {
return statement;
} else {
final int times = retry.times();
return new Statement() {
@Override public void evaluate() throws Throwable {
for (int i = 0; i <= times; i++) {
try {
statement.evaluate();
} catch (AssertionError e) {
throw annotate(i + 1, times, e);
// We purposefully catch any non-assertion exceptions in order to tag the try count
// for erroring (as opposed to failing) tests.
// SUPPRESS CHECKSTYLE RegexpSinglelineJava
} catch (Exception e) {
throw annotate(i + 1, times, e);
}
}
}
};
}
}
}
}