package org.inferred.freebuilder.processor.util.testing; import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.Iterables.getOnlyElement; import com.google.common.collect.ImmutableSet; import org.inferred.freebuilder.processor.util.testing.BehaviorTestRunner.Shared; import org.inferred.freebuilder.processor.util.testing.BehaviorTester.CompilationSubject; import org.inferred.freebuilder.processor.util.testing.TestBuilder.TestSource; import org.junit.rules.ExpectedException; import org.junit.runner.Description; import org.junit.runner.notification.Failure; import org.junit.runner.notification.RunNotifier; import org.junit.runners.model.FrameworkField; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.InitializationError; import org.junit.runners.model.Statement; import org.junit.runners.model.TestClass; import java.lang.reflect.Field; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Queue; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; import javax.annotation.processing.Processor; import javax.tools.JavaFileObject; /** * Code shared between BehaviorTestRunner and ParameterizedBehaviorTest */ public class SharedBehaviorTesting { interface TestSupplier { Object get() throws Exception; } /** Error thrown if no tests can share a compiler. */ private static final String MERGE_FAILURE_MESSAGE = "Failed to merge any test compilation steps; " + "have you correctly defined equals and hashCode on your processor?"; /** Error thrown by {@link ExpectedException} if no {@link CompilationException} is thrown. */ private static final String COMPILATION_FAILURE_EXPECTED = "Expected test to throw an instance of " + CompilationException.class.getName(); /** The @Shared BehaviorTester field on the test class. */ private final Field testerField; /** The "Introspection" test, which fails if no tests can share a compiler. */ private final Description introspection; private final Function<RunNotifier, Statement> superChildrenInvoker; private final BiConsumer<FrameworkMethod, RunNotifier> superChildRunner; private final TestSupplier superCreateTest; private final Supplier<Description> superDescription; /** Metadata about the child tests, built by {@link #getChildMetadata()}. */ private List<Child> children; /** The BehaviorTester to inject into test fixtures created by {@link #createTest()}. */ private BehaviorTester tester; public SharedBehaviorTesting( Function<RunNotifier, Statement> superChildrenInvoker, BiConsumer<FrameworkMethod, RunNotifier> superChildRunner, TestSupplier superCreateTest, Supplier<Description> superDescription, Supplier<TestClass> testClass, BiFunction<Class<?>, String, Description> descriptionFactory) throws InitializationError { this.superChildrenInvoker = superChildrenInvoker; this.superChildRunner = superChildRunner; this.superCreateTest = superCreateTest; this.superDescription = superDescription; List<FrameworkField> testerFields = testClass.get().getAnnotatedFields(Shared.class); if (testerFields.isEmpty()) { throw new InitializationError("No public @Shared field found"); } else if (testerFields.size() > 1) { throw new InitializationError("Multiple public @Shared fields found"); } FrameworkField frameworkField = getOnlyElement(testerFields); if (!frameworkField.isPublic()) { throw new InitializationError("@Shared field " + frameworkField + " must be public"); } if (!frameworkField.getType().isAssignableFrom(BehaviorTester.class)) { throw new InitializationError(String.format( "@Shared field %s must be of type %s", frameworkField, BehaviorTester.class.getSimpleName())); } testerField = frameworkField.getField(); introspection = descriptionFactory.apply(testClass.get().getJavaClass(), "Introspect"); } /** * Returns a {@link Description} showing the {@link #introspection} task, plus the children of * this runner. */ public Description getDescription() { Description originalDescription = superDescription.get(); if (originalDescription.getChildren().size() > 1) { Description suiteDescription = originalDescription.childlessCopy(); suiteDescription.addChild(introspection); originalDescription.getChildren().forEach(suiteDescription::addChild); return suiteDescription; } else { // Allow individual tests to be rerun without adding the "Introspect" task. return originalDescription; } } /** * Returns a statement that runs all children with compilations coalesced for performance. */ public Statement childrenInvoker(RunNotifier notifier) { return new Statement() { @Override public void evaluate() throws Throwable { runChildren(notifier); } }; } /** * Runs all children with compilations coalesced for performance. */ private void runChildren(RunNotifier notifier) throws Throwable { if (superDescription.get().getChildren().size() > 1) { Queue<SharedCompiler> sharedCompilers = getSharedCompilers(notifier); while (!sharedCompilers.isEmpty()) { // Removing each shared compiler from the queue as we start it ensures it can be // garbage-collected as soon as we're finished with it. SharedCompiler sharedCompiler = sharedCompilers.remove(); for (FrameworkMethod child : sharedCompiler.children) { runChild(notifier, sharedCompiler, child); } } } else { // Rerun individual tests with the default BehaviorTester. tester = BehaviorTester.create(); try { superChildrenInvoker.apply(notifier).evaluate(); } finally { tester = null; } } } /** * Determines how many compilers we need and which children to pass them to. */ private Queue<SharedCompiler> getSharedCompilers(RunNotifier notifier) throws Throwable { notifier.fireTestStarted(introspection); try { List<Child> children = getChildMetadata(); Queue<SharedCompiler> sharedCompilers = shareCompilers(children); verifyCompilerShared(notifier, children.size(), sharedCompilers.size()); notifier.fireTestFinished(introspection); return sharedCompilers; } catch (Throwable t) { notifier.fireTestFailure(new Failure(introspection, t)); throw t; } } /** * Pre-runs children to find out what they want to compile. */ private List<Child> getChildMetadata() throws Throwable { children = new ArrayList<>(); try { Statement childrenInvoker = superChildrenInvoker.apply(new RunNotifier() {}); childrenInvoker.evaluate(); return children; } finally { children = null; } } /** * Pre-runs child, injecting a {@link Child} behavior tester to determine what they pass to the * compiler. */ public void runChild(FrameworkMethod method, RunNotifier notifier) { if (tester != null) { // Only one child, no pre-run needed. superChildRunner.accept(method, notifier); return; } Child child = new Child(method); tester = child; try { superChildRunner.accept(method, new RunNotifier() { @Override public void fireTestFailure(Failure failure) { if (COMPILATION_FAILURE_EXPECTED.equals(failure.getMessage())) { // Test is expecting compilation to fail, so merging with other tests will only // propagate the failure. Mark the test as unmergeable. child.unmergeable = true; } } }); } finally { tester = null; children.add(child); } } /** * Runs {@code child} with {@code sharedCompiler}. */ private void runChild( RunNotifier notifier, SharedCompiler sharedCompiler, FrameworkMethod child) { tester = new DelegatingBehaviorTester(sharedCompiler); try { superChildRunner.accept(child, notifier); } finally { tester = null; } } /** * Returns a new fixture for running a test. Executes the test class's no-argument constructor, * then injects the correct BehaviorTester implementation. */ public Object createTest() throws Exception { checkState(tester != null); Object test = superCreateTest.get(); // Inject the correct BehaviorTester implementation. testerField.set(test, tester); return test; } /** * Groups children that can share a compiler. */ private static Queue<SharedCompiler> shareCompilers(List<Child> children) { Queue<SharedCompiler> sharedCompilers = new ArrayDeque<>(); for (Child child : children) { Optional<SharedCompiler> sharedCompiler = sharedCompilers .stream() .filter(c -> c.canShareCompiler(child)) .findAny(); if (sharedCompiler.isPresent()) { sharedCompiler.get().addChild(child); } else { sharedCompilers.add(new SharedCompiler(child)); } } return sharedCompilers; } /** * Fails the {@link #introspection} test unless we managed to share a compiler. */ private void verifyCompilerShared(RunNotifier notifier, int numChildren, int numCompilations) { System.out.println(String.format( "Merged %d tests into %d compiler passes", numChildren, numCompilations)); int numFilteredChildren = superDescription.get().getChildren().size(); if (numFilteredChildren == numChildren) { if (numChildren == numCompilations) { notifier.fireTestFailure(new Failure( introspection, new AssertionError(MERGE_FAILURE_MESSAGE))); } } } /** * A BehaviorTester that just remembers the processors, units and tests it was asked to compile. */ private static class Child implements BehaviorTester { final FrameworkMethod method; boolean unmergeable = false; final Set<Processor> processors = new LinkedHashSet<>(); final List<JavaFileObject> compilationUnits = new ArrayList<>(); final List<TestSource> testSources = new ArrayList<>(); Child(FrameworkMethod method) { this.method = method; } @Override public String toString() { return method.getName(); } @Override public BehaviorTester with(Processor processor) { processors.add(processor); return this; } @Override public BehaviorTester with(JavaFileObject compilationUnit) { compilationUnits.add(compilationUnit); return this; } @Override public BehaviorTester with(TestSource testSource) { testSources.add(testSource); return this; } @Override public BehaviorTester withContextClassLoader() { return this; } @Override public CompilationSubject compiles() { return new CompilationSubject() { @Override public CompilationSubject withNoWarnings() { return this; } @Override public CompilationSubject allTestsPass() { return this; } @Override public CompilationSubject testsPass( Iterable<? extends TestSource> testSources, boolean shouldSetContextClassLoader) { return this; } }; } } /** * A compiler that can be shared between a set of children. */ private static class SharedCompiler { private final Set<FrameworkMethod> children = new LinkedHashSet<>(); final boolean unmergeable; Set<Processor> processors; final Map<String, JavaFileObject> compilationUnits = new LinkedHashMap<>(); final List<TestSource> testSources = new ArrayList<>(); private CompilationSubject subject; SharedCompiler(Child child) { children.add(child.method); unmergeable = child.unmergeable; processors = ImmutableSet.copyOf(child.processors); for (JavaFileObject compilationUnit : child.compilationUnits) { compilationUnits.put(compilationUnit.getName(), compilationUnit); } testSources.addAll(child.testSources); } boolean canShareCompiler(Child child) { if (unmergeable || child.unmergeable) { return false; } if (!processors.equals(child.processors)) { return false; } for (JavaFileObject otherUnit : child.compilationUnits) { JavaFileObject unit = compilationUnits.get(otherUnit.getName()); if (unit != null && !unit.equals(otherUnit)) { return false; } } return true; } SharedCompiler addChild(Child child) { children.add(child.method); for (JavaFileObject compilationUnit : child.compilationUnits) { compilationUnits.put(compilationUnit.getName(), compilationUnit); } testSources.addAll(child.testSources); return this; } public CompilationSubject compiles() { if (subject == null) { SingleBehaviorTester tester = new SingleBehaviorTester(); for (Processor processor : processors) { tester.with(processor); } for (JavaFileObject compilationUnit : compilationUnits.values()) { tester.with(compilationUnit); } for (TestSource testSource : testSources) { tester.with(testSource); } subject = tester.compiles(); processors = null; // Allow compilers to be reclaimed by GC (processors retain a reference) } return subject; } } /** * A BehaviorTester that uses a shared {@link SharedCompiler} to reduce overheads. */ private static class DelegatingBehaviorTester implements BehaviorTester { private final SharedCompiler compilation; private final List<TestSource> testSources = new ArrayList<>(); private boolean shouldSetContextClassLoader = false; DelegatingBehaviorTester(SharedCompiler compilation) { this.compilation = compilation; } @Override public BehaviorTester with(Processor processor) { return this; } @Override public BehaviorTester with(JavaFileObject compilationUnit) { return this; } @Override public BehaviorTester with(TestSource testSource) { testSources.add(testSource); return this; } @Override public BehaviorTester withContextClassLoader() { shouldSetContextClassLoader = true; return this; } @Override public CompilationSubject compiles() { CompilationSubject assertCompiled = compilation.compiles(); return new CompilationSubject() { @Override public CompilationSubject withNoWarnings() { assertCompiled.withNoWarnings(); return this; } @Override public CompilationSubject allTestsPass() { assertCompiled.testsPass(testSources, shouldSetContextClassLoader); return this; } @Override public CompilationSubject testsPass( Iterable<? extends TestSource> testSources, boolean shouldSetContextClassLoader) { assertCompiled.testsPass(testSources, shouldSetContextClassLoader); return this; } }; } } }