// Copyright 2019 The Nomulus Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License 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 google.registry.webdriver; import static com.google.common.base.Preconditions.checkState; import com.google.common.flogger.FluentLogger; import java.lang.reflect.Field; import java.util.List; import java.util.stream.Collectors; import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.runners.BlockJUnit4ClassRunner; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.InitializationError; import org.junit.runners.model.Statement; /** * A JUnit test runner which can retry each test up to 3 times. * *

To use this runner, annotate the test class with {@link RepeatableRunner} and define a field * with type of {@link AttemptNumber}: * *

 * @RunWith(RepeatableRunner.class)
 * public class RepeatableRunnerTest {
 *   private AttemptNumber attemptNumber = new AttemptNumber();
 *
 *   @Test
 *   public void test() {
 *     print(attemptNumber.get());
 *   }
 * }
 * 
* *

This runner is for our visual regression to prevent flakes during the run. The test is * repeated multiple times until it passes and it only fails if it failed 3 times. */ public class RepeatableRunner extends BlockJUnit4ClassRunner { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final boolean RUN_ALL_ATTEMPTS = Boolean.parseBoolean(System.getProperty("test.screenshot.runAllAttempts", "false")); private static final int MAX_ATTEMPTS = Integer.parseInt(System.getProperty("test.screenshot.maxAttempts", "3")); private final Field attemptNumberField; private AttemptNumber lastAttemptNumber; /** Constructs a new instance of the default runner */ public RepeatableRunner(Class clazz) throws InitializationError { super(clazz); attemptNumberField = getAttemptNumberField(); } private Field getAttemptNumberField() { List attemptNumberFields = FieldUtils.getAllFieldsList(getTestClass().getJavaClass()).stream() .filter(declaredField -> declaredField.getType().equals(AttemptNumber.class)) .collect(Collectors.toList()); if (attemptNumberFields.size() == 0) { throw new IllegalArgumentException("Missing a field with type of AttemptNumber"); } else if (attemptNumberFields.size() > 1) { throw new IllegalArgumentException( "Cannot have more than 1 field with type of AttemptNumber"); } Field attemptNumberField = attemptNumberFields.get(0); // It should not matter if that field is set to private attemptNumberField.setAccessible(true); return attemptNumberField; } @Override protected Object createTest() throws Exception { Object testObject = super.createTest(); // lastAttemptNumber must be null at this moment to indicate that we have // created the RepeatableStatement for the previous AttemptNumber object // or this is the first time we run the test. // If it is not the case, either the tests are run in parallel or the // behavior of BlockJUnit4ClassRunner is changed. checkState(lastAttemptNumber == null); lastAttemptNumber = (AttemptNumber) attemptNumberField.get(testObject); return testObject; } @Override protected Statement methodBlock(FrameworkMethod method) { Statement statement = super.methodBlock(method); RepeatableStatement repeatableStatement = new RepeatableStatement(statement, method, lastAttemptNumber); // Explicitly set lastAttemptNumber to null because it should // not be reused accidentally by the next test. lastAttemptNumber = null; return repeatableStatement; } /** A simple POJO to store the number of the test attempt. */ public static class AttemptNumber { private int attemptNumber; /** Returns the number of the test attempt. */ public int get() { return attemptNumber; } private void set(int attemptNumber) { this.attemptNumber = attemptNumber; } } private static class RepeatableStatement extends Statement { private Statement statement; private FrameworkMethod method; private AttemptNumber attemptNumber; public RepeatableStatement( Statement statement, FrameworkMethod method, AttemptNumber attemptNumber) { this.statement = statement; this.method = method; this.attemptNumber = attemptNumber; } @Override public void evaluate() throws Throwable { checkState(MAX_ATTEMPTS > 0); int numSuccess = 0, numFailure = 0; Throwable lastException = null; for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { attemptNumber.set(attempt); try { statement.evaluate(); numSuccess++; logger.atInfo().log( "[%s] Attempt %d of %d succeeded!\n", method.getName(), attempt, MAX_ATTEMPTS); if (!RUN_ALL_ATTEMPTS) { return; } } catch (Throwable e) { numFailure++; lastException = e; logger.atWarning().log( "[%s] Attempt %d of %d failed!\n", method.getName(), attempt, MAX_ATTEMPTS); } } logger.atInfo().log( "Test [%s] was executed %d times, %d attempts succeeded and %d attempts failed.", method.getName(), numSuccess + numFailure, numSuccess, numFailure); if (numSuccess == 0) { logger.atSevere().log( "[%s] didn't pass after all %d attempts failed!\n", method.getName(), MAX_ATTEMPTS); // In most cases, setting RUN_ALL_ATTEMPTS to true is to find the golden image, so we should // not throw an exception to reduce confusion if (!RUN_ALL_ATTEMPTS) { throw lastException; } } } } }