// Copyright 2013 Google Inc. 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 com.google.j2objc.testing;
import com.google.j2objc.annotations.AutoreleasePool;
import com.google.j2objc.annotations.WeakOuter;
import junit.framework.Test;
import junit.runner.Version;
import org.junit.internal.TextListener;
import org.junit.runner.Description;
import org.junit.runner.JUnitCore;
import org.junit.runner.Result;
import org.junit.runner.RunWith;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
import org.junit.runners.JUnit4;
import org.junit.runners.Suite;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Set;
/*-[
#include <objc/runtime.h>
]-*/
/**
* Runs JUnit test classes.
*
* Provides a main() function that runs all JUnit tests linked into the executable.
* The main() function accepts no arguments since Pulse unit tests are not designed to accept
* arguments. Instead the code expects a file called "JUnitTestRunner.properties" to be include
* as a resource.
*
* Any classes derived from {@link Test} (JUnit 3) or {@link Suite} (JUnit 4) are considered
* JUnit tests. This behavior can be changed by overriding {@link #isJUnitTestClass},
* {@link #isJUnit3TestClass} or {@link #isJUnit4TestClass}.
*
* @author iroth@google.com (Ian Roth)
*/
public class JUnitTestRunner {
private static final String PROPERTIES_FILE_NAME = "JUnitTestRunner.properties";
/**
* Specifies the output format for tests.
*/
public enum OutputFormat {
JUNIT, // JUnit style output.
GTM_UNIT_TESTING // Google Toolkit for Mac unit test output format.
}
/**
* Specifies the sort order for tests.
*/
public enum SortOrder {
ALPHABETICAL, // Sorted alphabetically
RANDOM // Sorted randomly (differs with each run)
}
/**
* Specifies whether a pattern includes or excludes test classes.
*/
public enum TestInclusion {
RUN_TEST, // Includes tests that exactly match the pattern
INCLUDE, // Includes test classes matching the pattern
EXCLUDE // Excludes test classes matching the pattern
}
private final PrintStream out;
private final Set<String> testsToRun = new HashSet<>();
private final Set<String> includePatterns = new HashSet<>();
private final Set<String> excludePatterns = new HashSet<>();
private final Map<String, String> nameMappings = new HashMap<>();
private final Map<String, String> randomNames = new HashMap<>();
private final Random random = new Random(System.currentTimeMillis());
private OutputFormat outputFormat = OutputFormat.JUNIT;
private SortOrder sortOrder = SortOrder.ALPHABETICAL;
public JUnitTestRunner() {
this(System.err);
}
public JUnitTestRunner(PrintStream out) {
this.out = out;
}
public static int main(String[] args) {
// Create JUnit test runner.
JUnitTestRunner runner = new JUnitTestRunner();
runner.loadPropertiesFromResource(PROPERTIES_FILE_NAME);
return runner.run();
}
/**
* Runs the test classes given in {@param classes}.
* @returns Zero if all tests pass, non-zero otherwise.
*/
public static int run(Class<?>[] classes, RunListener listener) {
JUnitCore junitCore = new JUnitCore();
junitCore.addListener(listener);
boolean hasError = false;
for (@AutoreleasePool Class<?> c : classes) {
Result result = junitCore.run(c);
hasError = hasError || !result.wasSuccessful();
}
return hasError ? 1 : 0;
}
/**
* Runs the test classes that match settings in {@link #PROPERTIES_FILE_NAME}.
* @returns Zero if all tests pass, non-zero otherwise.
*/
public int run() {
if (outputFormat == OutputFormat.GTM_UNIT_TESTING) {
Thread.setDefaultUncaughtExceptionHandler(new GtmUncaughtExceptionHandler());
}
Set<Class<?>> classesSet = getTestClasses();
Class<?>[] classes = classesSet.toArray(new Class<?>[classesSet.size()]);
sortClasses(classes, sortOrder);
RunListener listener = newRunListener(outputFormat);
return run(classes, listener);
}
/**
* Returns a new {@link RunListener} instance for the given {@param outputFormat}.
*/
public RunListener newRunListener(OutputFormat outputFormat) {
switch (outputFormat) {
case JUNIT:
out.println("JUnit version " + Version.id());
return new TextListener(out);
case GTM_UNIT_TESTING:
return new GtmUnitTestingTextListener();
default:
throw new IllegalArgumentException("outputFormat");
}
}
/**
* Sorts the classes given in {@param classes} according to {@param sortOrder}.
*/
public void sortClasses(Class<?>[] classes, final SortOrder sortOrder) {
Arrays.sort(classes, new Comparator<Class<?>>() {
@Override
public int compare(Class<?> class1, Class<?> class2) {
String name1 = getSortKey(class1, sortOrder);
String name2 = getSortKey(class2, sortOrder);
return name1.compareTo(name2);
}
});
}
private String replaceAll(String value) {
for (Map.Entry<String, String> entry : nameMappings.entrySet()) {
String pattern = entry.getKey();
String replacement = entry.getValue();
value = value.replaceAll(pattern, replacement);
}
return value;
}
private String getSortKey(Class<?> cls, SortOrder sortOrder) {
String className = cls.getName();
switch (sortOrder) {
case ALPHABETICAL:
return replaceAll(className);
case RANDOM:
String sortKey = randomNames.get(className);
if (sortKey == null) {
sortKey = Integer.toString(random.nextInt());
randomNames.put(className, sortKey);
}
return sortKey;
default:
throw new IllegalArgumentException("sortOrder");
}
}
/*-[
// Returns true if |cls| conforms to the NSObject protocol.
BOOL IsNSObjectClass(Class cls) {
while (cls != nil) {
if (class_conformsToProtocol(cls, @protocol(NSObject))) {
return YES;
}
// class_conformsToProtocol() does not examine superclasses.
cls = class_getSuperclass(cls);
}
return NO;
}
]-*/
/**
* Returns the set of all loaded JUnit test classes.
*/
private native Set<Class<?>> getAllTestClasses() /*-[
int classCount = objc_getClassList(NULL, 0);
Class *classes = (Class *)malloc(classCount * sizeof(Class));
objc_getClassList(classes, classCount);
id<JavaUtilSet> result = AUTORELEASE([[JavaUtilHashSet alloc] init]);
for (int i = 0; i < classCount; i++) {
@try {
Class cls = classes[i];
if (IsNSObjectClass(cls)) {
IOSClass *javaClass = IOSClass_fromClass(cls);
if ([self isJUnitTestClassWithIOSClass:javaClass]) {
[result addWithId:javaClass];
}
}
}
@catch (NSException *e) {
// Ignore any exceptions thrown by class initialization.
}
}
free(classes);
return result;
]-*/;
/**
* @return true if {@param cls} is either a JUnit 3 or JUnit 4 test.
*/
protected boolean isJUnitTestClass(Class<?> cls) {
return !Modifier.isAbstract(cls.getModifiers())
&& (isJUnit3TestClass(cls) || isJUnit4TestClass(cls));
}
/**
* @return true if {@param cls} derives from {@link Test} and is not part of the
* {@link junit.framework} package.
*/
protected boolean isJUnit3TestClass(Class<?> cls) {
if (Test.class.isAssignableFrom(cls)) {
String packageName = getPackageName(cls);
return !packageName.startsWith("junit.framework")
&& !packageName.startsWith("junit.extensions");
}
return false;
}
/**
* @return true if {@param cls} is {@link JUnit4} annotated.
*/
protected boolean isJUnit4TestClass(Class<?> cls) {
// Need to find test classes, otherwise crashes with b/11790448.
if (!cls.getName().endsWith("Test")) {
return false;
}
// Check the annotations.
Annotation annotation = cls.getAnnotation(RunWith.class);
if (annotation != null) {
RunWith runWith = (RunWith) annotation;
Object value = runWith.value();
if (value.equals(JUnit4.class) || value.equals(Suite.class)) {
return true;
}
}
return false;
}
/**
* Returns the name of a class's package or "" for the default package
* or (for Foundation classes) no package object.
*/
private String getPackageName(Class<?> cls) {
Package pkg = cls.getPackage();
return pkg != null ? pkg.getName() : "";
}
/**
* Returns the set of test classes that match settings in {@link #PROPERTIES_FILE_NAME}.
*/
private Set<Class<?>> getTestClasses() {
Set<Class<?>> testClasses = new HashSet<>();
for (String testName : testsToRun) {
try {
testClasses.add(Class.forName(testName));
} catch (ClassNotFoundException e) {
throw new AssertionError(e);
}
}
if (!includePatterns.isEmpty()) {
for (Class<?> testClass : getAllTestClasses()) {
for (String includePattern : includePatterns) {
if (matchesPattern(testClass, includePattern)) {
testClasses.add(testClass);
break;
}
}
}
}
if (testsToRun.isEmpty() && includePatterns.isEmpty()) {
// Include all tests if no include patterns specified.
testClasses.addAll(getAllTestClasses());
}
// Search included tests for tests to exclude.
Iterator<Class<?>> testClassesIterator = testClasses.iterator();
while (testClassesIterator.hasNext()) {
Class<?> testClass = testClassesIterator.next();
for (String excludePattern : excludePatterns) {
if (matchesPattern(testClass, excludePattern)) {
testClassesIterator.remove();
break;
}
}
}
return testClasses;
}
private boolean matchesPattern(Class<?> testClass, String pattern) {
return testClass.getCanonicalName().contains(pattern);
}
private void loadProperties(InputStream stream) {
Properties properties = new Properties();
try {
properties.load(stream);
} catch (IOException e) {
onError(e);
}
Set<String> propertyNames = properties.stringPropertyNames();
for (String key : propertyNames) {
String value = properties.getProperty(key);
try {
if (key.equals("outputFormat")) {
outputFormat = OutputFormat.valueOf(value);
} else if (key.equals("sortOrder")) {
sortOrder = SortOrder.valueOf(value);
} else if (value.equals(TestInclusion.RUN_TEST.name())) {
testsToRun.add(key);
} else if (value.equals(TestInclusion.INCLUDE.name())) {
includePatterns.add(key);
} else if (value.equals(TestInclusion.EXCLUDE.name())) {
excludePatterns.add(key);
} else {
nameMappings.put(key, value);
}
} catch (IllegalArgumentException e) {
onError(e);
}
}
}
private void loadPropertiesFromResource(String resourcePath) {
try {
InputStream stream = ClassLoader.getSystemClassLoader().getResourceAsStream(resourcePath);
if (stream != null) {
loadProperties(stream);
} else {
throw new IOException(String.format("Resource not found: %s", resourcePath));
}
} catch (Exception e) {
onError(e);
}
}
private void onError(Exception e) {
e.printStackTrace(out);
}
private class GtmUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
out.print("Exception in thread \"" + t.getName() + "\" ");
e.printStackTrace(out);
out.println("** TEST FAILED **");
}
}
@WeakOuter
private class GtmUnitTestingTextListener extends RunListener {
private int numTests = 0;
private int numFailures = 0;
private final int numUnexpected = 0; // Never changes, but required in output.
private Failure testFailure;
private double testStartTime;
@Override
public void testRunFinished(Result result) throws Exception {
printf("Executed %d tests, with %d failures (%d unexpected)\n", numTests, numFailures,
numUnexpected);
}
@Override
public void testStarted(Description description) throws Exception {
numTests++;
testFailure = null;
testStartTime = System.currentTimeMillis();
printf("Test Case '-[%s]' started.\n", parseDescription(description));
}
@Override
public void testFinished(Description description) throws Exception {
double testEndTime = System.currentTimeMillis();
double elapsedSeconds = 0.001 * (testEndTime - testStartTime);
String statusMessage = "passed";
if (testFailure != null) {
statusMessage = "failed";
out.print(testFailure.getTrace());
}
printf("Test Case '-[%s]' %s (%.3f seconds).\n\n", parseDescription(description),
statusMessage, elapsedSeconds);
}
@Override
public void testFailure(Failure failure) throws Exception {
testFailure = failure;
numFailures++;
}
private String parseDescription(Description description) {
String displayName = description.getDisplayName();
int p1 = displayName.indexOf("(");
int p2 = displayName.indexOf(")");
if (p1 < 0 || p2 < 0 || p2 <= p1) {
return displayName;
}
String methodName = displayName.substring(0, p1);
String className = displayName.substring(p1 + 1, p2);
return replaceAll(className) + " " + methodName;
}
private void printf(String format, Object... args) {
// Avoid using printf() or println() because they will be flushed in pieces and cause
// interleaving with logger messages.
out.print(String.format(format, args));
}
}
}