001: /*
002: * Copyright 2006-2007 The Kuali Foundation.
003: *
004: * Licensed under the Educational Community License, Version 1.0 (the "License");
005: * you may not use this file except in compliance with the License.
006: * You may obtain a copy of the License at
007: *
008: * http://www.opensource.org/licenses/ecl1.php
009: *
010: * Unless required by applicable law or agreed to in writing, software
011: * distributed under the License is distributed on an "AS IS" BASIS,
012: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013: * See the License for the specific language governing permissions and
014: * limitations under the License.
015: */
016: package org.kuali.test.suite;
017:
018: import java.io.File;
019: import java.io.IOException;
020: import java.lang.annotation.ElementType;
021: import java.lang.annotation.Retention;
022: import java.lang.annotation.RetentionPolicy;
023: import java.lang.annotation.Target;
024: import java.lang.reflect.Method;
025: import java.net.URISyntaxException;
026: import java.util.ArrayList;
027: import java.util.LinkedList;
028:
029: import junit.framework.TestCase;
030: import junit.framework.TestSuite;
031:
032: import org.kuali.core.util.AssertionUtils;
033:
034: /**
035: * Utility class that builds test suites dynamically.
036: *
037: * @see org.kuali.test.suite.ContextConfiguredSuite
038: * @see org.kuali.test.suite.ShouldCommitTransactionsSuite
039: * @see org.kuali.test.suite.CrossSectionSuite
040: */
041: public class TestSuiteBuilder {
042:
043: public static final NullCriteria NULL_CRITERIA = new NullCriteria();
044:
045: private static final Class<TestSuiteBuilder> THIS_CLASS = TestSuiteBuilder.class;
046: private static final String ROOT_PACKAGE = "org.kuali";
047:
048: /**
049: * Scans *Test.class files under org.kuali for matches against the given strategies.
050: *
051: * @param classCriteria strategy for whether to include a given TestCase in the suite. If included, a test class acts like a
052: * sub-suite to include all its test methods. Classes not included may still include methods individually.
053: * @param methodCriteria strategy for whether to include a given test method in the suite, if the whole class was not included.
054: * @return a TestSuite containing the specified tests
055: * @throws java.io.IOException if the directory containing this class file cannot be scanned for other test class files
056: * @throws Exception if either of the given criteria throw it
057: */
058: public static TestSuite build(ClassCriteria classCriteria,
059: MethodCriteria methodCriteria) throws Exception {
060: TestSuite suite = new TestSuite();
061: for (Class<? extends TestCase> t : constructTestClasses(scanTestClassNames(getTestRootPackageDir()))) {
062: if (t.isAnnotationPresent(Exclude.class)) {
063: continue; // don't consider any methods of this test either
064: }
065: if (classCriteria.includes(t)) {
066: suite.addTestSuite(t);
067: } else {
068: for (Method m : t.getMethods()) {
069: if (isTestMethod(m) && methodCriteria.includes(m)) {
070: suite.addTest(TestSuite.createTest(t, m
071: .getName()));
072: }
073: }
074: }
075: }
076: suite.setName(getDefaultName());
077: return suite;
078: }
079:
080: /**
081: * @return the name of the class calling this class
082: */
083: private static String getDefaultName() {
084: StackTraceElement[] stack = Thread.currentThread()
085: .getStackTrace();
086: return stack[4].getClassName();
087: }
088:
089: private static boolean isTestMethod(Method method) {
090: return method.getName().startsWith("test")
091: && method.getReturnType().equals(void.class)
092: && method.getParameterTypes().length == 0;
093: }
094:
095: private static ArrayList<Class<? extends TestCase>> constructTestClasses(
096: ArrayList<String> testClassNames) {
097: ArrayList<Class<? extends TestCase>> classes = new ArrayList<Class<? extends TestCase>>();
098: for (String name : testClassNames) {
099: try {
100: classes.add(Class.forName(name).asSubclass(
101: TestCase.class));
102: } catch (ClassCastException e) {
103: // Ignore this class.
104: // Its name ends with Test but it doesn't extend TestCase, so it's not really a test class.
105: // E.g., production class GenesisTest is put in build/test/classes by build.xml make-tests target.
106: } catch (ClassNotFoundException e) {
107: throw new AssertionError(e); // impossible because the .class file was under a classloader directory
108: }
109: }
110: return classes;
111: }
112:
113: /**
114: * @param testRootPackageDir the directory of the ROOT_PACKAGE containing test classes
115: * @return the list of fully qualified class names under that directory for each file name ending in "Test.class"
116: * @throws java.io.IOException if that directory cannot be scanned
117: */
118: private static ArrayList<String> scanTestClassNames(
119: File testRootPackageDir) throws IOException {
120: AssertionUtils
121: .assertThat(testRootPackageDir.getCanonicalPath()
122: .endsWith(
123: ROOT_PACKAGE.replace('.',
124: File.separatorChar)));
125: ArrayList<String> testClassNames = new ArrayList<String>();
126: LinkedList<File> dirs = new LinkedList<File>();
127: dirs.add(testRootPackageDir);
128: final int lengthOfPathToRootPackageDir = testRootPackageDir
129: .getCanonicalPath().length()
130: - ROOT_PACKAGE.length();
131: while (!dirs.isEmpty()) {
132: File currentDir = dirs.removeFirst();
133: LinkedList<File> subdirs = new LinkedList<File>();
134: for (File f : currentDir.listFiles()) {
135: if (f.isDirectory()) {
136: subdirs.addLast(f);
137: } else {
138: if (f.isFile()
139: && f.getName().endsWith("Test.class")) {
140: String className = f
141: .getCanonicalPath()
142: .substring(lengthOfPathToRootPackageDir)
143: .replace(File.separatorChar, '.');
144: testClassNames
145: .add(className.substring(0, className
146: .length()
147: - ".class".length()));
148: }
149: }
150: }
151: // implement depth-first directory traversal to correspond to Ant's junitreport, without using recursion
152: subdirs.addAll(dirs);
153: dirs = subdirs;
154: }
155: return testClassNames;
156: }
157:
158: /**
159: * @return the parent of the directory containing this test class file
160: */
161: private static File getTestRootPackageDir() {
162: try {
163: File this ClassFile = new File(THIS_CLASS.getResource(
164: THIS_CLASS.getSimpleName() + ".class").toURI());
165: return this ClassFile.getParentFile().getParentFile()
166: .getParentFile();
167: } catch (URISyntaxException e) {
168: throw new AssertionError(e); // if the classloader doesn't always return the "file:" protocol, then this method needs
169: // to be changed
170: }
171: }
172:
173: /**
174: * Unconditionally excludes the annotated test class (and all its methods) from any suite built by this class. This is useful
175: * with negative matching strategies, e.g., all test methods without the {@code @ShouldCommitTransactions} annotation, except
176: * the ones in SpringShutdownTest.
177: */
178: @Retention(RetentionPolicy.RUNTIME)
179: @Target(ElementType.TYPE)
180: public static @interface Exclude {
181: // no elements
182: }
183:
184: /**
185: * A Strategy pattern for choosing which test classes to include in a suite. A test class acts like a sub-suite to include all
186: * its test methods. For test classes that do not match, test methods can still be included individually.
187: */
188: public static interface ClassCriteria {
189:
190: /**
191: * @param testClass a TestCase to consider for the suite
192: * @return whether it should be included as a sub-suite
193: * @throws Exception if necessary
194: */
195: boolean includes(Class<? extends TestCase> testClass)
196: throws Exception;
197: }
198:
199: /**
200: * A Strategy pattern for choosing which test methods to include individually in a suite. This is not used if the method's whole
201: * TestCase was included.
202: */
203: public static interface MethodCriteria {
204:
205: /**
206: * @param testMethod a test method to consider for the suite. The method name starts with "test", takes no parameters, and
207: * returns void.
208: * @return whether it should be included
209: * @throws Exception if necessary
210: */
211: boolean includes(Method testMethod) throws Exception;
212: }
213:
214: /**
215: * A Singleton NullObject pattern that can be passed as the other strategy when using only one strategy. This works for either
216: * strategy. Using this for both strategies will build an empty suite.
217: */
218: private static class NullCriteria implements ClassCriteria,
219: MethodCriteria {
220:
221: public boolean includes(Class<? extends TestCase> testClass) {
222: return false;
223: }
224:
225: public boolean includes(Method testMethod) {
226: return false;
227: }
228: }
229:
230: /**
231: * A Decorator pattern to negate the strategy of a ClassCriteria.
232: */
233: public static class NegatingClassCriteria implements ClassCriteria {
234:
235: private final ClassCriteria decorated;
236:
237: public NegatingClassCriteria(ClassCriteria decorated) {
238: this .decorated = decorated;
239: }
240:
241: public boolean includes(Class<? extends TestCase> testClass)
242: throws Exception {
243: return !decorated.includes(testClass);
244: }
245: }
246:
247: /**
248: * A Decorator pattern to negate the strategy of a MethodCriteria.
249: */
250: public static class NegatingMethodCriteria implements
251: MethodCriteria {
252:
253: private final MethodCriteria decorated;
254:
255: public NegatingMethodCriteria(MethodCriteria decorated) {
256: this .decorated = decorated;
257: }
258:
259: public boolean includes(Method method) throws Exception {
260: return !decorated.includes(method);
261: }
262: }
263: }
|