/* * The Kuali Financial System, a comprehensive financial management system for higher education. * * Copyright 2005-2014 The Kuali Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kuali.kfs.sys.suite; import java.io.File; import java.io.IOException; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Method; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.LinkedList; import junit.framework.TestCase; import junit.framework.TestSuite; /** * Utility class that builds test suites dynamically. * * @see org.kuali.kfs.suite.ContextConfiguredSuite * @see org.kuali.test.suite.ShouldCommitTransactionsSuite * @see org.kuali.kfs.suite.CrossSectionSuite */ public class TestSuiteBuilder { public static final NullCriteria NULL_CRITERIA = new NullCriteria(); private static final Class<TestSuiteBuilder> THIS_CLASS = TestSuiteBuilder.class; private static final String ROOT_PACKAGE = "org.kuali"; /** * Scans *Test.class files under org.kuali for matches against the given strategies. * * @param classCriteria strategy for whether to include a given TestCase in the suite. If included, a test class acts like a * sub-suite to include all its test methods. Classes not included may still include methods individually. * @param methodCriteria strategy for whether to include a given test method in the suite, if the whole class was not included. * @return a TestSuite containing the specified tests * @throws java.io.IOException if the directory containing this class file cannot be scanned for other test class files * @throws Exception if either of the given criteria throw it */ public static TestSuite build(ClassCriteria classCriteria, MethodCriteria methodCriteria) throws Exception { TestSuite suite = new TestSuite(); for (Class<? extends TestCase> t : constructTestClasses(scanTestClassNames(getTestRootPackageDir()))) { if (t.isAnnotationPresent(Exclude.class)) { continue; // don't consider any methods of this test either } if (classCriteria.includes(t)) { suite.addTestSuite(t); } else { for (Method m : t.getMethods()) { if (isTestMethod(m) && methodCriteria.includes(m)) { suite.addTest(TestSuite.createTest(t, m.getName())); } } } } suite.setName(getDefaultName()); return suite; } /** * @return the name of the class calling this class */ private static String getDefaultName() { StackTraceElement[] stack = Thread.currentThread().getStackTrace(); return stack[4].getClassName(); } private static boolean isTestMethod(Method method) { return method.getName().startsWith("test") && method.getReturnType().equals(void.class) && method.getParameterTypes().length == 0; } private static ArrayList<Class<? extends TestCase>> constructTestClasses(ArrayList<String> testClassNames) { ArrayList<Class<? extends TestCase>> classes = new ArrayList<Class<? extends TestCase>>(); for (String name : testClassNames) { try { classes.add(Class.forName(name).asSubclass(TestCase.class)); } catch (ClassCastException e) { // Ignore this class. // Its name ends with Test but it doesn't extend TestCase, so it's not really a test class. // E.g., production class GenesisTest is put in build/test/classes by build.xml make-tests target. } catch (ClassNotFoundException e) { throw new AssertionError(e); // impossible because the .class file was under a classloader directory } } return classes; } /** * @param testRootPackageDir the directory of the ROOT_PACKAGE containing test classes * @return the list of fully qualified class names under that directory for each file name ending in "Test.class" * @throws java.io.IOException if that directory cannot be scanned */ private static ArrayList<String> scanTestClassNames(File testRootPackageDir) throws IOException { if(!testRootPackageDir.getCanonicalPath().endsWith(ROOT_PACKAGE.replace('.', File.separatorChar))) { throw new AssertionError(); } ArrayList<String> testClassNames = new ArrayList<String>(); LinkedList<File> dirs = new LinkedList<File>(); dirs.add(testRootPackageDir); final int lengthOfPathToRootPackageDir = testRootPackageDir.getCanonicalPath().length() - ROOT_PACKAGE.length(); while (!dirs.isEmpty()) { File currentDir = dirs.removeFirst(); LinkedList<File> subdirs = new LinkedList<File>(); for (File f : currentDir.listFiles()) { if (f.isDirectory()) { subdirs.addLast(f); } else { if (f.isFile() && f.getName().endsWith("Test.class")) { String className = f.getCanonicalPath().substring(lengthOfPathToRootPackageDir).replace(File.separatorChar, '.'); testClassNames.add(className.substring(0, className.length() - ".class".length())); } } } // implement depth-first directory traversal to correspond to Ant's junitreport, without using recursion subdirs.addAll(dirs); dirs = subdirs; } return testClassNames; } /** * @return the parent of the directory containing this test class file */ private static File getTestRootPackageDir() { try { return new File( new File( THIS_CLASS.getProtectionDomain().getCodeSource().getLocation().toURI() ), "org/kuali" ); } catch (URISyntaxException e) { throw new AssertionError(e); // if the classloader doesn't always return the "file:" protocol, then this method needs // to be changed } } /** * Unconditionally excludes the annotated test class (and all its methods) from any suite built by this class. This is useful * with negative matching strategies, e.g., all test methods without the {@code @ShouldCommitTransactions} annotation, except * the ones in ContinuousIntegrationStartup or ContinuousIntegrationShutdown. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public static @interface Exclude { // no elements } /** * A Strategy pattern for choosing which test classes to include in a suite. A test class acts like a sub-suite to include all * its test methods. For test classes that do not match, test methods can still be included individually. */ public static interface ClassCriteria { /** * @param testClass a TestCase to consider for the suite * @return whether it should be included as a sub-suite * @throws Exception if necessary */ boolean includes(Class<? extends TestCase> testClass) throws Exception; } /** * A Strategy pattern for choosing which test methods to include individually in a suite. This is not used if the method's whole * TestCase was included. */ public static interface MethodCriteria { /** * @param testMethod a test method to consider for the suite. The method name starts with "test", takes no parameters, and * returns void. * @return whether it should be included * @throws Exception if necessary */ boolean includes(Method testMethod) throws Exception; } /** * A Singleton NullObject pattern that can be passed as the other strategy when using only one strategy. This works for either * strategy. Using this for both strategies will build an empty suite. */ private static class NullCriteria implements ClassCriteria, MethodCriteria { public boolean includes(Class<? extends TestCase> testClass) { return false; } public boolean includes(Method testMethod) { return false; } } /** * A Decorator pattern to negate the strategy of a ClassCriteria. */ public static class NegatingClassCriteria implements ClassCriteria { private final ClassCriteria decorated; public NegatingClassCriteria(ClassCriteria decorated) { this.decorated = decorated; } public boolean includes(Class<? extends TestCase> testClass) throws Exception { return !decorated.includes(testClass); } } /** * A Decorator pattern to negate the strategy of a MethodCriteria. */ public static class NegatingMethodCriteria implements MethodCriteria { private final MethodCriteria decorated; public NegatingMethodCriteria(MethodCriteria decorated) { this.decorated = decorated; } public boolean includes(Method method) throws Exception { return !decorated.includes(method); } } }